scraps 3.7.0

Scraps is a portable CLI knowledge hub for managing interconnected Markdown documentation with Wiki-link notation.
use std::path::PathBuf;

use scraps_libs::git::GitCommand;
use scraps_libs::model::{scrap::Scrap, tags::Tags};

use crate::usecase::build::model::backlinks_map::BacklinksMap;
use crate::usecase::lint::rule::{LintRule, LintRuleName, LintWarning, scrap_relative_path};

const SECONDS_PER_DAY: i64 = 86_400;

/// Flag scraps whose latest git commit is older than `threshold_days`.
///
/// Maps to Notion's "Verified Pages" concept: knowledge bases accumulate
/// content that was once accurate but rots when left untouched. The default
/// 180-day threshold matches Notion's default re-verification cycle.
///
/// Opt-in (`[lint.stale_by_git]` or `--rule stale-by-git`): how old is
/// stale is a policy each wiki sets, not a defect in any wiki.
pub struct StaleByGitRule<GC: GitCommand> {
    pub git_command: GC,
    pub scraps_dir: PathBuf,
    pub threshold_days: u64,
    pub now_ts: i64,
}

impl<GC: GitCommand + Send + Sync> LintRule for StaleByGitRule<GC> {
    fn name(&self) -> LintRuleName {
        LintRuleName::StaleByGit
    }

    fn check(
        &self,
        scraps: &[Scrap],
        _backlinks_map: &BacklinksMap,
        _tags: &Tags,
    ) -> Vec<LintWarning> {
        let is_repo = self.git_command.is_git_repository(&self.scraps_dir);
        match is_repo {
            Ok(true) => {}
            Ok(false) => {
                eprintln!("info: stale-by-git: git unavailable, skipping stale check");
                return Vec::new();
            }
            Err(e) => {
                eprintln!(
                    "info: stale-by-git: git unavailable ({}), skipping stale check",
                    e
                );
                return Vec::new();
            }
        }

        let threshold_secs = (self.threshold_days as i64).saturating_mul(SECONDS_PER_DAY);
        let cutoff = self.now_ts.saturating_sub(threshold_secs);

        let scrap_paths: Vec<String> = scraps.iter().map(scrap_relative_path).collect();
        let rel_paths: Vec<&str> = scrap_paths.iter().map(String::as_str).collect();
        let timestamps = match self
            .git_command
            .commited_ts_many(&self.scraps_dir, &rel_paths)
        {
            Ok(timestamps) => timestamps,
            Err(e) => {
                eprintln!(
                    "info: stale-by-git: git unavailable ({}), skipping stale check",
                    e
                );
                return Vec::new();
            }
        };

        scrap_paths
            .into_iter()
            .zip(timestamps)
            .filter_map(|(scrap_path, ts)| {
                let ts = ts?;
                if ts >= cutoff {
                    return None;
                }
                let age_days = (self.now_ts - ts) / SECONDS_PER_DAY;
                Some(LintWarning {
                    rule_name: LintRuleName::StaleByGit,
                    scrap_path,
                    message: format!("scrap not updated in {} days", age_days),
                    source: None,
                    span: None,
                })
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use scraps_libs::git::tests::GitCommandTest;
    use std::io;
    use std::path::Path;
    use std::sync::Mutex;

    /// Stub git command that returns scripted timestamps and repo status.
    #[derive(Clone, Copy)]
    struct GitStub {
        ts: Option<i64>,
        is_repo: bool,
    }

    impl GitCommand for GitStub {
        fn init(&self, _path: &Path) -> io::Result<()> {
            Ok(())
        }
        fn commited_ts(&self, _path: &Path) -> io::Result<Option<i64>> {
            Ok(self.ts)
        }
        fn is_git_repository(&self, _path: &Path) -> io::Result<bool> {
            Ok(self.is_repo)
        }
    }

    fn now_ts() -> i64 {
        1_700_000_000
    }

    #[test]
    fn flag_scrap_older_than_threshold() {
        let now = now_ts();
        let old_ts = now - 200 * SECONDS_PER_DAY;
        let rule = StaleByGitRule {
            git_command: GitStub {
                ts: Some(old_ts),
                is_repo: true,
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![Scrap::new("old", &None, "body")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].rule_name, LintRuleName::StaleByGit);
        assert_eq!(warnings[0].scrap_path, "old.md");
        assert!(warnings[0].message.contains("200 days"));
    }

    #[test]
    fn skip_scrap_within_threshold() {
        let now = now_ts();
        let recent_ts = now - 30 * SECONDS_PER_DAY;
        let rule = StaleByGitRule {
            git_command: GitStub {
                ts: Some(recent_ts),
                is_repo: true,
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![Scrap::new("recent", &None, "body")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert!(warnings.is_empty());
    }

    #[test]
    fn skip_uncommitted_scrap() {
        let now = now_ts();
        let rule = StaleByGitRule {
            git_command: GitStub {
                ts: None,
                is_repo: true,
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![Scrap::new("brand_new", &None, "body")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert!(warnings.is_empty());
    }

    #[test]
    fn skip_when_not_a_git_repo() {
        let now = now_ts();
        let rule = StaleByGitRule {
            git_command: GitStub {
                ts: Some(0),
                is_repo: false,
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![Scrap::new("a", &None, "body")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert!(warnings.is_empty());
    }

    #[test]
    fn flag_scrap_with_context_path() {
        let now = now_ts();
        let old_ts = now - 365 * SECONDS_PER_DAY;
        let rule = StaleByGitRule {
            git_command: GitStub {
                ts: Some(old_ts),
                is_repo: true,
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![Scrap::new("note", &Some("ai".into()), "body")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].scrap_path, "ai/note.md");
    }

    /// Answers a whole batch per call, as `GitCommandImpl` does, and keeps
    /// every batch it was asked for.
    #[derive(Clone, Copy)]
    struct BatchGitStub {
        asked: &'static Mutex<Vec<Vec<String>>>,
        ts: Option<i64>,
    }

    impl GitCommand for BatchGitStub {
        fn init(&self, _path: &Path) -> io::Result<()> {
            Ok(())
        }
        fn commited_ts(&self, _path: &Path) -> io::Result<Option<i64>> {
            Ok(None)
        }
        fn commited_ts_many(
            &self,
            _dir: &Path,
            rel_paths: &[&str],
        ) -> io::Result<Vec<Option<i64>>> {
            let batch = rel_paths.iter().map(|p| p.to_string()).collect();
            self.asked.lock().unwrap().push(batch);
            Ok(vec![self.ts; rel_paths.len()])
        }
        fn is_git_repository(&self, _path: &Path) -> io::Result<bool> {
            Ok(true)
        }
    }

    #[test]
    fn ask_git_once_for_every_scrap() {
        let now = now_ts();
        let asked: &'static Mutex<Vec<Vec<String>>> = Box::leak(Box::new(Mutex::new(Vec::new())));
        let rule = StaleByGitRule {
            git_command: BatchGitStub {
                asked,
                ts: Some(now - 365 * SECONDS_PER_DAY),
            },
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now,
        };
        let scraps = vec![
            Scrap::new("a", &None, "body"),
            Scrap::new("note", &Some("ai".into()), "body"),
        ];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);

        assert_eq!(*asked.lock().unwrap(), vec![vec!["a.md", "ai/note.md"]]);
        assert_eq!(warnings.len(), 2);
    }

    #[test]
    fn default_test_git_returns_zero_so_old_scraps_flagged() {
        // Sanity check that scraps_libs GitCommandTest still works as a stub.
        let rule = StaleByGitRule {
            git_command: GitCommandTest::new(),
            scraps_dir: PathBuf::from("/tmp"),
            threshold_days: 180,
            now_ts: now_ts(),
        };
        let scraps = vec![Scrap::new("a", &None, "")];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = rule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
    }
}