use anyhow::{Result, anyhow};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
const MAX_USERNAME_LEN: usize = 30;
#[derive(Debug)]
enum Line {
Entry {
name: String,
excluded: bool,
raw: String,
},
Other(String),
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MergeOutcome {
pub added: usize,
pub removed: usize,
pub excluded: usize,
pub active: usize,
pub deletions_skipped: bool,
}
pub fn merge_friends(
path: &str,
harvested: &[String],
allow_deletes: bool,
) -> Result<MergeOutcome> {
let text = match fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(anyhow!("follows file read {path}: {e}")),
};
let lines: Vec<Line> = text.lines().map(parse_line).collect();
let harvest = clean(harvested);
let harvest_set: HashSet<&str> = harvest.iter().map(String::as_str).collect();
let active_names: HashSet<&str> = lines
.iter()
.filter_map(|l| match l {
Line::Entry {
name,
excluded: false,
..
} => Some(name.as_str()),
_ => None,
})
.collect();
let entry_count = lines
.iter()
.filter_map(|l| match l {
Line::Entry { name, .. } => Some(name.as_str()),
_ => None,
})
.collect::<HashSet<_>>()
.len();
let deletes = allow_deletes && !shrank_suspiciously(harvest.len(), entry_count);
let mut out: Vec<String> = Vec::with_capacity(lines.len() + harvest.len());
let mut emitted: HashSet<String> = HashSet::new();
let mut outcome = MergeOutcome {
deletions_skipped: !deletes,
..Default::default()
};
for line in &lines {
match line {
Line::Other(raw) => out.push(raw.clone()),
Line::Entry {
name,
excluded,
raw,
} => {
if emitted.contains(name) || (*excluded && active_names.contains(name.as_str())) {
continue;
}
emitted.insert(name.clone());
if deletes && !harvest_set.contains(name.as_str()) {
outcome.removed += 1;
continue;
}
out.push(raw.clone());
if *excluded {
outcome.excluded += 1;
} else {
outcome.active += 1;
}
}
}
}
for name in &harvest {
if emitted.insert(name.clone()) {
out.push(name.clone());
outcome.added += 1;
outcome.active += 1;
}
}
write_lines(path, &out)?;
Ok(outcome)
}
pub fn read_friends(path: &str) -> Result<Vec<String>> {
let text = match fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(anyhow!("follows file read {path}: {e}")),
};
let mut seen = HashSet::new();
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let name = line.strip_prefix('@').unwrap_or(line).trim();
if name.is_empty() {
continue;
}
if seen.insert(name.to_string()) {
out.push(name.to_string());
}
}
Ok(out)
}
fn shrank_suspiciously(harvest_len: usize, existing_entries: usize) -> bool {
harvest_len * 2 < existing_entries
}
fn parse_line(raw: &str) -> Line {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Line::Other(raw.to_string());
}
let (body, excluded) = match trimmed.strip_prefix('#') {
Some(rest) => (rest.trim(), true),
None => (trimmed, false),
};
let name = body.strip_prefix('@').unwrap_or(body).trim();
if is_username(name) {
Line::Entry {
name: name.to_string(),
excluded,
raw: raw.to_string(),
}
} else {
Line::Other(raw.to_string())
}
}
fn is_username(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_USERNAME_LEN
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
}
fn clean(usernames: &[String]) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(usernames.len());
for u in usernames {
let name = u.trim();
let name = name.strip_prefix('@').unwrap_or(name).trim();
if is_username(name) && seen.insert(name.to_string()) {
out.push(name.to_string());
}
}
out
}
fn write_lines(path: &str, lines: &[String]) -> Result<()> {
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|e| anyhow!("follows file mkdir: {e}"))?;
}
}
let mut body = lines.join("\n");
if !body.is_empty() {
body.push('\n');
}
fs::write(path, body).map_err(|e| anyhow!("follows file write {path}: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join("instagrab-cache-test");
fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
fn seed(name: &str, body: &str) -> String {
let p = tmp(name);
fs::write(&p, body).unwrap();
p
}
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn skips_comments_blanks_and_dupes() {
let p = seed("read.txt", "# header\n\n@zuck\nzuck\n plain \n@\n");
assert_eq!(read_friends(&p).unwrap(), vec!["zuck", "plain"]);
}
#[test]
fn missing_file_is_empty() {
let p = tmp("does-not-exist.txt");
let _ = fs::remove_file(&p);
assert_eq!(read_friends(&p).unwrap(), Vec::<String>::new());
}
#[test]
fn missing_file_writes_the_harvest() {
let p = tmp("merge-fresh.txt");
let _ = fs::remove_file(&p);
let outcome = merge_friends(&p, &names(&["@a", "b"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "a\nb\n");
assert_eq!(outcome.added, 2);
assert_eq!(outcome.removed, 0);
assert!(!outcome.deletions_skipped);
}
#[test]
fn exclusion_survives_a_harvest() {
let p = seed(
"merge-exclusion.txt",
"# porthole manages this file\nanniebannie\n# nononancy\nberryp\n",
);
let outcome =
merge_friends(&p, &names(&["anniebannie", "nononancy", "berryp"]), true).unwrap();
assert_eq!(
fs::read_to_string(&p).unwrap(),
"# porthole manages this file\nanniebannie\n# nononancy\nberryp\n"
);
assert_eq!(outcome.excluded, 1);
assert_eq!(outcome.active, 2);
assert_eq!(outcome.added, 0);
assert_eq!(outcome.removed, 0);
assert_eq!(read_friends(&p).unwrap(), vec!["anniebannie", "berryp"]);
}
#[test]
fn unfollowed_entries_are_dropped_active_or_excluded() {
let p = seed(
"merge-drop.txt",
"keeper\ngone\n# excluded_gone\n# excluded_kept\n",
);
let outcome = merge_friends(&p, &names(&["keeper", "excluded_kept"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "keeper\n# excluded_kept\n");
assert_eq!(outcome.removed, 2);
assert_eq!(outcome.excluded, 1);
assert_eq!(outcome.active, 1);
}
#[test]
fn new_follows_are_appended_in_harvest_order() {
let p = seed("merge-append.txt", "existing\n");
let outcome = merge_friends(&p, &names(&["existing", "newer", "newest"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "existing\nnewer\nnewest\n");
assert_eq!(outcome.added, 2);
}
#[test]
fn prose_blanks_and_formatting_are_preserved_verbatim() {
let p = seed(
"merge-prose.txt",
"# instagrab follows list\n#\n\n @zuck \n\n# a note about the next one\nplain\n",
);
merge_friends(&p, &names(&["zuck", "plain"]), true).unwrap();
assert_eq!(
fs::read_to_string(&p).unwrap(),
"# instagrab follows list\n#\n\n @zuck \n\n# a note about the next one\nplain\n"
);
}
#[test]
fn partial_harvest_never_deletes() {
let p = seed("merge-partial.txt", "a\nb\nc\n# d\n");
let outcome = merge_friends(&p, &names(&["a", "e"]), false).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "a\nb\nc\n# d\ne\n");
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.added, 1);
assert!(outcome.deletions_skipped);
}
#[test]
fn suspicious_shrink_withholds_deletes() {
let p = seed("merge-shrink.txt", "a\nb\nc\nd\ne\nf\n");
let outcome = merge_friends(&p, &names(&["a", "b"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "a\nb\nc\nd\ne\nf\n");
assert_eq!(outcome.removed, 0);
assert!(outcome.deletions_skipped);
let p = seed("merge-shrink-half.txt", "a\nb\nc\nd\n");
let outcome = merge_friends(&p, &names(&["a", "b"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "a\nb\n");
assert_eq!(outcome.removed, 2);
assert!(!outcome.deletions_skipped);
}
#[test]
fn empty_harvest_keeps_everything() {
let p = seed("merge-empty.txt", "a\nb\n");
let outcome = merge_friends(&p, &[], true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "a\nb\n");
assert!(outcome.deletions_skipped);
}
#[test]
fn duplicates_collapse_the_way_read_friends_resolves_them() {
for body in ["# zuck\nzuck\n", "zuck\n# zuck\n", "zuck\n@zuck\n"] {
let p = seed("merge-dupe.txt", body);
let before = read_friends(&p).unwrap();
let outcome = merge_friends(&p, &names(&["zuck"]), true).unwrap();
assert_eq!(
read_friends(&p).unwrap(),
before,
"changed scan list: {body:?}"
);
assert_eq!(outcome.active, 1, "{body:?}");
assert_eq!(outcome.excluded, 0, "{body:?}");
}
}
#[test]
fn merge_is_idempotent() {
let p = seed("merge-idempotent.txt", "# header note\na\n# b\n");
merge_friends(&p, &names(&["a", "b", "c"]), true).unwrap();
let once = fs::read_to_string(&p).unwrap();
let outcome = merge_friends(&p, &names(&["a", "b", "c"]), true).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), once);
assert_eq!(outcome.added, 0);
assert_eq!(outcome.removed, 0);
}
}