use anyhow::Result;
use serde_json::{json, Value};
use crate::librarian::catalog::augmentation;
use crate::librarian::tools::{RecoverableError, ToolContext};
use super::scope::Scope;
#[derive(serde::Deserialize)]
struct Args {
threshold_hours: Option<u32>,
limit: Option<usize>,
scope: Option<Scope>,
}
const MAX_LIMIT: usize = 50;
const DEFAULT_THRESHOLD_HOURS: u32 = 24;
const DEFAULT_LIMIT: usize = 10;
pub async fn call(ctx: &ToolContext, args: Value) -> Result<Value> {
let a: Args = serde_json::from_value(args)?;
let threshold_hours = a.threshold_hours.unwrap_or(DEFAULT_THRESHOLD_HOURS);
let limit = a.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let scope = a.scope.unwrap_or(Scope::Project);
let current = ctx.current_project.as_deref();
let abs_path_prefix: Option<&std::path::Path> = match scope {
Scope::All => None,
Scope::Repo => {
let cp = current.ok_or_else(|| {
RecoverableError::new(
"scope=repo requires a resolved current project. Pass scope=\"all\".",
)
})?;
Some(cp.git_root.as_path())
}
Scope::Project => {
let cp = current.ok_or_else(|| {
RecoverableError::new(
"scope=project requires a resolved current project. Pass scope=\"all\".",
)
})?;
Some(cp.abs_path.as_path())
}
Scope::Umbrella => {
return Err(RecoverableError::new(
"scope=umbrella is not supported. Use scope=project|repo|all.",
));
}
};
let threshold_iso = {
let cutoff = chrono::Utc::now() - chrono::Duration::hours(i64::from(threshold_hours));
cutoff.to_rfc3339()
};
let entries = {
let cat = ctx.catalog.lock();
augmentation::list_stale(&cat, &threshold_iso, limit, abs_path_prefix)?
};
let now = chrono::Utc::now();
let items: Vec<Value> = entries
.iter()
.map(|e| {
let age_hours = e.last_refreshed_at.as_deref().and_then(|t| {
chrono::DateTime::parse_from_rfc3339(t)
.ok()
.map(|dt| (now - dt.with_timezone(&chrono::Utc)).num_hours())
});
json!({
"id": e.artifact_id,
"kind": e.kind,
"title": e.title,
"abs_path": e.abs_path.display().to_string(),
"last_refreshed_at": e.last_refreshed_at,
"refresh_count": e.refresh_count,
"age_hours": age_hours,
})
})
.collect();
let next_step = if items.is_empty() {
"No stale augmented artifacts in scope.".to_string()
} else {
"Call artifact_refresh(id) on each item, synthesize updates, \
then artifact_update(id, commit_refresh=true)."
.to_string()
};
Ok(json!({
"count": items.len(),
"threshold_hours": threshold_hours,
"items": items,
"next_step": next_step,
}))
}
#[cfg(test)]
mod tests {
use crate::librarian::catalog::artifact::ArtifactRow;
use crate::librarian::catalog::{artifact, augmentation, Catalog};
fn sample_art(id: &str, repo: &str, rel_path: &str) -> ArtifactRow {
ArtifactRow {
id: id.into(),
abs_path: std::path::PathBuf::from(format!("/{repo}/{rel_path}")),
kind: "tracker".into(),
status: "active".into(),
title: Some(format!("Tracker {id}")),
owners: vec![],
tags: vec![],
topic: None,
time_scope: None,
source: None,
created_at: 0,
updated_at: 0,
file_mtime: 0,
file_sha256: "abc".into(),
confidence: 1.0,
}
}
fn aug_row(
artifact_id: &str,
last_refreshed_at: Option<&str>,
) -> augmentation::AugmentationRow {
augmentation::AugmentationRow {
artifact_id: artifact_id.into(),
prompt: "keep updated".into(),
params: "{}".into(),
last_refreshed_at: last_refreshed_at.map(str::to_string),
refresh_count: 0,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
render_template: None,
params_schema: None,
append_mode: false,
history_cap: None,
}
}
#[test]
fn list_stale_returns_never_refreshed_first() {
let cat = Catalog::open_in_memory().unwrap();
artifact::upsert(&cat, &sample_art("a1", "claude", "proj/t1.md")).unwrap();
artifact::upsert(&cat, &sample_art("a2", "claude", "proj/t2.md")).unwrap();
augmentation::upsert(&cat, &aug_row("a1", None)).unwrap();
augmentation::upsert(&cat, &aug_row("a2", Some("2000-01-01T00:00:00Z"))).unwrap();
let entries = augmentation::list_stale(&cat, "9999-01-01T00:00:00Z", 10, None).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].artifact_id, "a1");
assert!(entries[0].last_refreshed_at.is_none());
}
#[test]
fn list_stale_threshold_filters_fresh() {
let cat = Catalog::open_in_memory().unwrap();
artifact::upsert(&cat, &sample_art("a1", "claude", "proj/t1.md")).unwrap();
artifact::upsert(&cat, &sample_art("a2", "claude", "proj/t2.md")).unwrap();
augmentation::upsert(&cat, &aug_row("a1", Some("9999-01-01T00:00:00Z"))).unwrap();
augmentation::upsert(&cat, &aug_row("a2", None)).unwrap();
let threshold = chrono::Utc::now().to_rfc3339();
let entries = augmentation::list_stale(&cat, &threshold, 10, None).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].artifact_id, "a2");
}
#[test]
fn list_stale_repo_scope_filters() {
let cat = Catalog::open_in_memory().unwrap();
artifact::upsert(&cat, &sample_art("a1", "claude", "proj/t1.md")).unwrap();
artifact::upsert(&cat, &sample_art("a2", "other", "proj/t2.md")).unwrap();
augmentation::upsert(&cat, &aug_row("a1", None)).unwrap();
augmentation::upsert(&cat, &aug_row("a2", None)).unwrap();
let prefix = std::path::Path::new("/claude");
let entries =
augmentation::list_stale(&cat, "9999-01-01T00:00:00Z", 10, Some(prefix)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].artifact_id, "a1");
}
#[test]
fn list_stale_prefix_normalizes_separator() {
let cat = Catalog::open_in_memory().unwrap();
let mut row = sample_art("a1", "claude", "proj/t1.md");
row.abs_path = std::path::PathBuf::from("C:\\roots\\alive\\a.md");
artifact::upsert(&cat, &row).unwrap();
augmentation::upsert(&cat, &aug_row("a1", None)).unwrap();
let prefix = std::path::Path::new("C:\\roots\\alive");
let entries =
augmentation::list_stale(&cat, "9999-01-01T00:00:00Z", 10, Some(prefix)).unwrap();
assert_eq!(
entries.len(),
1,
"backslash-form prefix should match forward-slash-stored abs_path"
);
assert_eq!(entries[0].artifact_id, "a1");
}
#[test]
fn list_stale_subdir_scope_filters() {
let cat = Catalog::open_in_memory().unwrap();
artifact::upsert(&cat, &sample_art("a1", "claude", "code-explorer/t1.md")).unwrap();
artifact::upsert(&cat, &sample_art("a2", "claude", "mempalace/t2.md")).unwrap();
augmentation::upsert(&cat, &aug_row("a1", None)).unwrap();
augmentation::upsert(&cat, &aug_row("a2", None)).unwrap();
let prefix = std::path::Path::new("/claude/code-explorer");
let entries =
augmentation::list_stale(&cat, "9999-01-01T00:00:00Z", 10, Some(prefix)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].artifact_id, "a1");
}
#[test]
fn list_stale_limit_respected() {
let cat = Catalog::open_in_memory().unwrap();
for i in 0..5 {
let id = format!("a{i}");
artifact::upsert(&cat, &sample_art(&id, "claude", &format!("proj/t{i}.md"))).unwrap();
augmentation::upsert(&cat, &aug_row(&id, None)).unwrap();
}
let entries = augmentation::list_stale(&cat, "9999-01-01T00:00:00Z", 3, None).unwrap();
assert_eq!(entries.len(), 3);
}
}