//! The global denominator's rule set: which repositories' commits count as //! Tangled activity, from evidence about each one. //! //! Every repository gets an evidence vector. Each signal says something about //! whether its commits are work done on Tangled, and each has a tier: //! //! | tier | for | against | //! |--------|---------------------------------------------|---------------------------------| //! | manual | — | excludes file, fork record, description says copy | //! | strong | Tangled-identity authors; history born on Tangled | a large author crowd with no Tangled identity; stopped at the page cap | //! | medium | small team; owner keeps other repos; an original description | many authors before the repo existed | //! | weak | complete walk; plausible size (shown only) | history before the repo existed (shown only) | //! //! The verdict, in order: a manual signal excludes. A strong negative //! excludes, or holds the repo for review when a strong positive contradicts //! it. A page-cap stop holds. Otherwise the repo counts with one strong //! positive, two medium ones, or one medium and no medium negative, and is //! excluded for want of evidence otherwise. A repo with nothing counted is //! `empty`. Separately, many authors before the repo existed (an imported //! community history) takes away the repo's grace period, so only its //! commits from its Tangled creation date count; and at the merge a repo //! over [`RuleParams::hold_share_pct`] of any window is held unless reviewed. //! //! Everything is recomputed from the rules on every run; nothing sticks. The //! parameters, the excludes file and the reviewed list hash to one value that //! every summary carries, so a changed rule set is visible and can be refused. use std::collections::BTreeSet; use std::path::Path; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::config::Config; use crate::db::Db; use crate::described; /// Bump with any change to a threshold, a signal or the verdict order. pub const RULES_VERSION: &str = "evidence-v1"; /// The rule set's parameters. The evidence thresholds are frozen in code for /// a version; only the older crawl filters come from the environment. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RuleParams { /// Tangled's start: nothing earlier counts (TALLY_GLOBAL_IMPORTED_BEFORE). pub imported_before: String, /// Commits up to this many days before a repo's creation count /// (TALLY_GLOBAL_REPO_AGE_GRACE_DAYS), unless the repo loses its grace. pub repo_age_grace_days: i64, pub skip_forks: bool, pub skip_described_copies: bool, /// `foreign_crowd`: at least this many distinct authors ... pub crowd_min_authors: u64, /// ... and under this percentage of commits by Tangled identities. pub crowd_max_tangled_pct: f64, /// `imported_burst`: this many distinct authors before the repo existed. pub import_min_authors: u64, /// `small_team`: at most this many distinct authors. pub small_team_max_authors: u64, /// `born_on_tangled`: under this percentage of commits before creation. pub born_max_pre_creation_pct: f64, /// `plausible_size` (weak): under this many counted commits. pub plausible_max_commits: u64, /// Held for review at the merge: over this percentage of any window ... pub hold_share_pct: f64, /// ... and at least this many changes in it, so a small window or a test /// database doesn't hold everything. pub hold_share_min_changes: u64, /// Count a rewritten commit without a Change-Id once: same author email, /// author time to the second and subject. pub collapse_rebased: bool, /// Commits dated more than this many days after the run don't count. pub future_slack_days: i64, } impl RuleParams { pub fn v1(imported_before: &str, grace_days: i64, forks: bool, described: bool) -> Self { Self { imported_before: imported_before.to_string(), repo_age_grace_days: grace_days, skip_forks: forks, skip_described_copies: described, crowd_min_authors: 50, crowd_max_tangled_pct: 0.5, import_min_authors: 10, small_team_max_authors: 10, born_max_pre_creation_pct: 50.0, plausible_max_commits: 20_000, hold_share_pct: 5.0, hold_share_min_changes: 1_000, collapse_rebased: true, future_slack_days: 1, } } } /// The rules one run applies: parameters, the excludes file and the list of /// held repositories the owner has reviewed and released. #[derive(Debug, Clone, PartialEq)] pub struct RuleSet { pub params: RuleParams, pub excludes: BTreeSet, pub reviewed: BTreeSet, } /// What a summary records about the rules that made it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RulesManifest { pub version: String, /// SHA-256 of the version, parameters, excludes and reviewed list. pub hash: String, pub params: RuleParams, pub excludes: usize, pub reviewed: Vec, } impl RuleSet { pub fn from_config(config: &Config) -> Result { Ok(Self { params: RuleParams::v1( &config.global_imported_before, config.global_repo_age_grace_days, config.global_skip_forks, config.global_skip_described_copies, ), excludes: load_did_list(&config.global_excludes)?, reviewed: load_did_list(&config.global_reviewed)?, }) } #[cfg(test)] pub fn for_tests() -> Self { Self { params: RuleParams::v1("2025-01-01", 30, true, true), excludes: BTreeSet::new(), reviewed: BTreeSet::new(), } } pub fn hash(&self) -> String { let canonical = serde_json::json!({ "version": RULES_VERSION, "params": self.params, "excludes": self.excludes, "reviewed": self.reviewed, }); hex::encode(Sha256::digest(canonical.to_string().as_bytes())) } pub fn manifest(&self) -> RulesManifest { RulesManifest { version: RULES_VERSION.to_string(), hash: self.hash(), params: self.params.clone(), excludes: self.excludes.len(), reviewed: self.reviewed.iter().cloned().collect(), } } /// The SQLite date modifier for a repo's grace period. pub fn grace_modifier(&self) -> String { format!("-{} days", self.params.repo_age_grace_days) } /// The latest author time that counts, `now` plus the slack. pub fn future_cap(&self, now: chrono::DateTime) -> String { (now + chrono::Duration::days(self.params.future_slack_days)) .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } } /// The windows a share is shown for, ending on `end`: a label and the first /// day, the way the site counts them (`end` and the days before it, so 30d /// is 30 dates), and all time from `imported_before`. pub fn windows(end: chrono::NaiveDate, imported_before: &str) -> [(&'static str, String); 4] { let back = |days: i64| (end - chrono::Duration::days(days - 1)).to_string(); [ ("30d", back(30)), ("90d", back(90)), ("1y", back(365)), ("all", imported_before.to_string()), ] } /// What the merge needs to decide holds: the share threshold and the /// owner's reviewed list. #[derive(Debug, Clone)] pub struct HoldPolicy { pub share_pct: f64, pub min_changes: u64, pub imported_before: String, pub reviewed: BTreeSet, } impl RuleSet { pub fn hold_policy(&self) -> HoldPolicy { HoldPolicy { share_pct: self.params.hold_share_pct, min_changes: self.params.hold_share_min_changes, imported_before: self.params.imported_before.clone(), reviewed: self.reviewed.clone(), } } } /// A repository the published denominator leaves out until the owner /// reviews it, with what it would add. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct HeldRepo { /// The repository DID. #[serde(alias = "repo_did")] pub repo: String, /// Changes it would add to the published (default-branch) count, all /// time. pub changes: u64, /// `page_cap`, `conflict` or `share`. pub reason: String, pub name: String, pub url: String, /// Its largest share of a window, for a `share` hold. #[serde(default, skip_serializing_if = "Option::is_none")] pub share_pct: Option, /// Changes it would add to each window, default branch and every branch. pub changes_by_window: std::collections::BTreeMap, pub changes_all_branches: std::collections::BTreeMap, pub signals: Vec, } /// One repository's evidence and verdict, for a summary. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RepoVerdict { pub repo_did: String, pub name: String, pub url: String, pub verdict: String, pub reason: String, pub commits: u64, pub authors: u64, pub tangled_commits: u64, pub pre_creation: u64, pub pre_creation_authors: u64, pub history_before: u64, pub signals: Vec, } /// One DID per line; `#` comments and a `tangled:` prefix allowed. A missing /// file is an empty list. pub fn load_did_list(path: &Path) -> Result> { let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()), Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), }; Ok(text .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(|line| line.strip_prefix("tangled:").unwrap_or(line).to_string()) .collect()) } /// What the crawl knows about one repository: its record, and its own /// commits from the grace floor on (every branch, nothing past the cap). #[derive(Debug, Clone, Default, PartialEq)] pub struct RepoFacts { pub repo_did: String, pub owner_did: String, pub fork: bool, pub description: Option, /// The last walk stopped at the page cap. pub truncated: bool, /// Non-fork repositories with the same owner, this one included. pub owner_repos: u64, pub commits: u64, pub authors: u64, /// Commits by a Tangled identity: a DID author email, or an author the /// crawl resolved to one of the owner's identities or agents. pub tangled_commits: u64, /// Commits authored before the repo's creation day (UTC), and by how many /// distinct authors. pub pre_creation: u64, pub pre_creation_authors: u64, /// Commits seen from before the floor: stored ones, or the most a walk /// has passed over. pub history_before: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Signal { ExcludesFile, ForkRecord, DescribedCopy, TangledIdentity, BornOnTangled, ForeignCrowd, PageCap, SmallTeam, OwnerActive, DescribedOriginal, ImportedBurst, CompleteWalk, PlausibleSize, PreTangledHistory, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Tier { Manual, Strong, Medium, Weak, } impl Signal { pub fn tier(self) -> Tier { match self { Signal::ExcludesFile | Signal::ForkRecord | Signal::DescribedCopy => Tier::Manual, Signal::TangledIdentity | Signal::BornOnTangled | Signal::ForeignCrowd | Signal::PageCap => Tier::Strong, Signal::SmallTeam | Signal::OwnerActive | Signal::DescribedOriginal | Signal::ImportedBurst => Tier::Medium, Signal::CompleteWalk | Signal::PlausibleSize | Signal::PreTangledHistory => Tier::Weak, } } /// Evidence that the repo's commits are Tangled work. pub fn positive(self) -> bool { matches!( self, Signal::TangledIdentity | Signal::BornOnTangled | Signal::SmallTeam | Signal::OwnerActive | Signal::DescribedOriginal | Signal::CompleteWalk | Signal::PlausibleSize ) } pub fn as_str(self) -> &'static str { match self { Signal::ExcludesFile => "excludes_file", Signal::ForkRecord => "fork_record", Signal::DescribedCopy => "described_copy", Signal::TangledIdentity => "tangled_identity", Signal::BornOnTangled => "born_on_tangled", Signal::ForeignCrowd => "foreign_crowd", Signal::PageCap => "page_cap", Signal::SmallTeam => "small_team", Signal::OwnerActive => "owner_active", Signal::DescribedOriginal => "described_original", Signal::ImportedBurst => "imported_burst", Signal::CompleteWalk => "complete_walk", Signal::PlausibleSize => "plausible_size", Signal::PreTangledHistory => "pre_tangled_history", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Verdict { Include, Exclude, Hold, Empty, } impl Verdict { pub fn as_str(self) -> &'static str { match self { Verdict::Include => "include", Verdict::Exclude => "exclude", Verdict::Hold => "hold", Verdict::Empty => "empty", } } } #[derive(Debug, Clone, PartialEq)] pub struct Evidence { pub signals: Vec, pub verdict: Verdict, /// The rule that decided: a signal's name, or `strong_positive`, /// `medium_positive`, `insufficient_evidence`, `conflict`, `reviewed`, /// `no_counted_commits`. pub reason: &'static str, /// Only commits from the creation day on count. pub no_grace: bool, } impl Evidence { pub fn has(&self, signal: Signal) -> bool { self.signals.contains(&signal) } } /// The evidence about one repository and the verdict it leads to. pub fn evaluate(facts: &RepoFacts, rules: &RuleSet) -> Evidence { let p = &rules.params; let pct = |part: u64, whole: u64| part as f64 * 100.0 / whole.max(1) as f64; let mut signals = Vec::new(); let mut add = |on: bool, signal: Signal| { if on { signals.push(signal); } }; add( rules.excludes.contains(&facts.repo_did), Signal::ExcludesFile, ); add(p.skip_forks && facts.fork, Signal::ForkRecord); add( p.skip_described_copies && facts .description .as_deref() .and_then(described::copy_reason) .is_some(), Signal::DescribedCopy, ); add(facts.tangled_commits > 0, Signal::TangledIdentity); add( facts.commits > 0 && facts.history_before == 0 && !facts.truncated && pct(facts.pre_creation, facts.commits) < p.born_max_pre_creation_pct, Signal::BornOnTangled, ); add( facts.authors >= p.crowd_min_authors && pct(facts.tangled_commits, facts.commits) < p.crowd_max_tangled_pct, Signal::ForeignCrowd, ); add(facts.truncated, Signal::PageCap); add( facts.authors > 0 && facts.authors <= p.small_team_max_authors, Signal::SmallTeam, ); add(facts.owner_repos >= 2, Signal::OwnerActive); add( facts .description .as_deref() .is_some_and(|text| !text.trim().is_empty() && described::copy_reason(text).is_none()), Signal::DescribedOriginal, ); add( facts.pre_creation_authors >= p.import_min_authors, Signal::ImportedBurst, ); add(!facts.truncated, Signal::CompleteWalk); add( facts.commits < p.plausible_max_commits, Signal::PlausibleSize, ); add(facts.history_before > 0, Signal::PreTangledHistory); let count = |tier: Tier, positive: bool| { signals .iter() .filter(|signal| signal.tier() == tier && signal.positive() == positive) .count() }; let strong_for = count(Tier::Strong, true); let medium_for = count(Tier::Medium, true); let medium_against = count(Tier::Medium, false); let manual = signals.iter().find(|signal| signal.tier() == Tier::Manual); let (verdict, reason) = if let Some(signal) = manual { (Verdict::Exclude, signal.as_str()) } else if facts.commits == 0 { (Verdict::Empty, "no_counted_commits") } else if signals.contains(&Signal::ForeignCrowd) { if strong_for > 0 { (Verdict::Hold, "conflict") } else { (Verdict::Exclude, Signal::ForeignCrowd.as_str()) } } else if facts.truncated { (Verdict::Hold, Signal::PageCap.as_str()) } else if strong_for > 0 { (Verdict::Include, "strong_positive") } else if medium_for >= 2 || (medium_for == 1 && medium_against == 0) { (Verdict::Include, "medium_positive") } else { (Verdict::Exclude, "insufficient_evidence") }; // The owner's review releases a hold; it never overrides a manual rule. let (verdict, reason) = if verdict == Verdict::Hold && rules.reviewed.contains(&facts.repo_did) { (Verdict::Include, "reviewed") } else { (verdict, reason) }; let no_grace = signals.contains(&Signal::ImportedBurst); Evidence { signals, verdict, reason, no_grace, } } /// How many repositories and counted commits ended in each verdict. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Tally { pub repos: u64, pub commits: u64, } #[derive(Debug, Default)] pub struct Applied { pub hash: String, /// `verdict:reason` → tally. pub verdicts: std::collections::BTreeMap, } /// Recompute every repository's evidence and verdict from `rules` and store /// them: the exclusions (repos a manual rule removes; the crawl skips them) /// are replaced, not added to, so removing a rule or an exclude brings a /// repo back. `cap` is the latest author time that counts. pub fn apply(db: &Db, rules: &RuleSet, cap: &str) -> Result { let facts = db.repo_facts(&rules.params.imported_before, &rules.grace_modifier(), cap)?; let hash = rules.hash(); let mut applied = Applied { hash: hash.clone(), ..Default::default() }; let decisions: Vec<(RepoFacts, Evidence)> = facts .into_iter() .map(|facts| { let evidence = evaluate(&facts, rules); (facts, evidence) }) .collect(); for (facts, evidence) in &decisions { let tally = applied .verdicts .entry(format!("{}:{}", evidence.verdict.as_str(), evidence.reason)) .or_default(); tally.repos += 1; tally.commits += facts.commits; } db.store_evidence(&decisions, &hash)?; Ok(applied) } #[cfg(test)] mod tests { use super::*; fn rules() -> RuleSet { RuleSet::for_tests() } /// Facts measured on the 22:05Z global database (2026-10-25), counted /// commits from each repo's grace floor on. fn nixpkgs_mirror() -> RepoFacts { // did:plc:ruj3bdjdfuathhxwapp45nxw, knot.pyrox.dev, description "lol". RepoFacts { repo_did: "did:plc:ruj3bdjdfuathhxwapp45nxw".into(), owner_did: "did:plc:5cqzysioqzttihsnbsaxrggu".into(), description: Some("lol".into()), truncated: true, owner_repos: 30, commits: 39_866, authors: 1_640, tangled_commits: 0, pre_creation: 34, pre_creation_authors: 18, ..Default::default() } } fn tangled_core() -> RepoFacts { // did:plc:…/core (tangled.org/core): 142 authors, 264 DID commits. RepoFacts { repo_did: "did:plc:core".into(), description: Some("tightly-knit social coding".into()), owner_repos: 12, commits: 11_484, authors: 142, tangled_commits: 264, pre_creation: 413, pre_creation_authors: 9, history_before: 0, ..Default::default() } } fn owners_repo() -> RepoFacts { // permadeath.com/didbot: every commit by a DID author. RepoFacts { repo_did: "did:plc:didbot".into(), owner_did: "did:plc:nlzmjyfv6loqtxyzvdcznwgf".into(), owner_repos: 9, commits: 1_872, authors: 1, tangled_commits: 1_872, ..Default::default() } } #[test] fn the_nixpkgs_mirror_is_excluded_by_its_authors_not_its_name() { let evidence = evaluate(&nixpkgs_mirror(), &rules()); assert_eq!(evidence.verdict, Verdict::Exclude); assert_eq!(evidence.reason, "foreign_crowd"); assert!(evidence.has(Signal::PageCap)); // A walk that finished is still excluded: the page cap isn't why. It // would have passed over upstream history from before the floor. let whole = RepoFacts { truncated: false, history_before: 1_000, ..nixpkgs_mirror() }; assert_eq!(evaluate(&whole, &rules()).reason, "foreign_crowd"); } #[test] fn a_crowd_with_tangled_identities_is_counted() { let evidence = evaluate(&tangled_core(), &rules()); assert_eq!(evidence.verdict, Verdict::Include); assert!(evidence.has(Signal::TangledIdentity) && evidence.has(Signal::BornOnTangled)); assert!(!evidence.has(Signal::ForeignCrowd)); assert!(!evidence.no_grace); } #[test] fn the_owners_repos_are_counted() { let evidence = evaluate(&owners_repo(), &rules()); assert_eq!( (evidence.verdict, evidence.reason), (Verdict::Include, "strong_positive") ); // vibescrobble.com: its first commit predates its record by six // days, the case the blog post's native rule dropped. let early = RepoFacts { commits: 937, pre_creation: 120, ..owners_repo() }; assert_eq!(evaluate(&early, &rules()).verdict, Verdict::Include); } #[test] fn an_imported_upstream_loses_its_grace_or_its_place() { // codex: 1,378 of 1,393 commits from 162 authors before creation. let codex = RepoFacts { repo_did: "did:plc:codex".into(), owner_repos: 4, commits: 1_393, authors: 163, pre_creation: 1_378, pre_creation_authors: 162, ..Default::default() }; let evidence = evaluate(&codex, &rules()); assert_eq!(evidence.verdict, Verdict::Exclude); assert!(evidence.no_grace); // ghostty-like: 28 authors, imported, owner keeps other repos. let ghostty = RepoFacts { authors: 28, pre_creation_authors: 27, commits: 211, pre_creation: 209, history_before: 16_141, ..codex }; let evidence = evaluate(&ghostty, &rules()); assert_eq!( (evidence.verdict, evidence.reason), (Verdict::Exclude, "insufficient_evidence") ); } #[test] fn a_moved_personal_repo_counts() { // dotfiles moved to Tangled: history from elsewhere, one author. let dotfiles = RepoFacts { repo_did: "did:plc:dotfiles".into(), owner_repos: 1, commits: 338, authors: 2, pre_creation: 48, pre_creation_authors: 1, history_before: 828, ..Default::default() }; let evidence = evaluate(&dotfiles, &rules()); assert_eq!( (evidence.verdict, evidence.reason), (Verdict::Include, "medium_positive") ); assert!(evidence.has(Signal::PreTangledHistory)); } #[test] fn conflicts_and_page_caps_are_held_until_reviewed() { // A mirror the owner also commits to through Tangled. let mixed = RepoFacts { authors: 60, commits: 10_000, tangled_commits: 8, ..nixpkgs_mirror() }; let evidence = evaluate(&mixed, &rules()); assert_eq!( (evidence.verdict, evidence.reason), (Verdict::Hold, "conflict") ); // An agent repo that stopped at the cap. let capped = RepoFacts { truncated: true, ..owners_repo() }; assert_eq!(evaluate(&capped, &rules()).reason, "page_cap"); let mut reviewed = rules(); reviewed.reviewed.insert(capped.repo_did.clone()); let evidence = evaluate(&capped, &reviewed); assert_eq!( (evidence.verdict, evidence.reason), (Verdict::Include, "reviewed") ); } #[test] fn manual_rules_come_first_and_follow_the_switches() { let fork = RepoFacts { fork: true, ..owners_repo() }; assert_eq!(evaluate(&fork, &rules()).reason, "fork_record"); let mut off = rules(); off.params.skip_forks = false; assert_eq!(evaluate(&fork, &off).verdict, Verdict::Include); let mirror = RepoFacts { description: Some("[READ-ONLY] Mirror of https://github.com/x/y".into()), ..owners_repo() }; assert_eq!(evaluate(&mirror, &rules()).reason, "described_copy"); let mut listed = rules(); listed.excludes.insert(owners_repo().repo_did); listed.reviewed.insert(owners_repo().repo_did); assert_eq!(evaluate(&owners_repo(), &listed).reason, "excludes_file"); let empty = RepoFacts { commits: 0, authors: 0, tangled_commits: 0, ..owners_repo() }; assert_eq!(evaluate(&empty, &rules()).verdict, Verdict::Empty); } #[test] fn the_hash_follows_every_input() { let base = rules(); let mut seen = std::collections::HashSet::new(); assert!(seen.insert(base.hash())); let mut changed = base.clone(); changed.params.crowd_min_authors = 49; assert!(seen.insert(changed.hash())); let mut changed = base.clone(); changed.excludes.insert("did:plc:x".into()); assert!(seen.insert(changed.hash())); let mut changed = base.clone(); changed.reviewed.insert("did:plc:x".into()); assert!(seen.insert(changed.hash())); assert_eq!(base.hash(), rules().hash()); assert_eq!(base.manifest().version, RULES_VERSION); } #[test] fn exclusions_follow_the_rules_of_each_run() { use crate::model::Repo; let db = Db::in_memory().unwrap(); for (did, fork) in [("did:plc:listed", false), ("did:plc:fork", true)] { db.upsert_repo(&Repo { repo_did: did.into(), owner_did: "did:plc:someone".into(), name: did.into(), knot: "knot.test".into(), url: String::new(), website: None, fork, created_at: None, active: true, last_scanned: None, last_error: None, }) .unwrap(); } let active = |db: &Db| { let mut dids: Vec = db .list_active_repos() .unwrap() .into_iter() .map(|repo| repo.repo_did) .collect(); dids.sort(); dids }; let mut listed = rules(); listed.excludes.insert("did:plc:listed".into()); let applied = apply(&db, &listed, "2026-10-27T00:00:00Z").unwrap(); assert!(active(&db).is_empty()); assert_eq!(applied.verdicts["exclude:excludes_file"].repos, 1); assert_eq!(applied.verdicts["exclude:fork_record"].repos, 1); // Take the DID out of the list and stop skipping forks: both return. let mut none = rules(); none.params.skip_forks = false; apply(&db, &none, "2026-10-27T00:00:00Z").unwrap(); assert_eq!(active(&db), ["did:plc:fork", "did:plc:listed"]); } #[test] fn did_lists_accept_comments_and_the_tangled_prefix() { let mut file = tempfile::NamedTempFile::new().unwrap(); use std::io::Write; writeln!(file, "# keep\ndid:plc:one\ntangled:did:plc:two\n").unwrap(); let values = load_did_list(file.path()).unwrap(); assert_eq!( values.into_iter().collect::>(), ["did:plc:one", "did:plc:two"] ); assert!( load_did_list(Path::new("/nonexistent/list.txt")) .unwrap() .is_empty() ); } }