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;
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;
#[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");
}
#[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() {
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);
}
}