Skip to main content

spec_driven_docs/commands/
track.rs

1//! `track` subcommand: runtime-shape.
2//!
3//! `status` reads the registry and reports freshness offline. `check` is the
4//! one network-enabled operation: it compares each pinned Git revision to its
5//! upstream through the `git` adapter and writes nothing. Registry parsing
6//! and freshness live in `services::tracking`; the network boundary lives in
7//! `adapters::git`.
8
9use camino::{Utf8Path, Utf8PathBuf};
10use jiff::civil::Date;
11
12use crate::adapters::git::{self, GitError};
13use crate::cli::track::{CheckArgs, StatusArgs, TrackArgs, TrackCommand};
14use crate::context::AppContext;
15use crate::error::AppError;
16use crate::gates::GateCtx;
17use crate::gates::paths::docs_root;
18use crate::output;
19use crate::services::tracking::{Freshness, evaluate, today_utc};
20
21/// Dispatch `sdd track`.
22///
23/// # Errors
24///
25/// [`AppError`] per the operation: usage errors, I/O, a `git` failure, or
26/// [`AppError::Violations`] when `--fail-on-update` sees a moved revision.
27pub fn run(ctx: &AppContext, args: TrackArgs) -> Result<(), AppError> {
28    match args.command {
29        TrackCommand::Status(a) => status(ctx, a),
30        TrackCommand::Check(a) => check(ctx, a),
31    }
32}
33
34fn resolve(ctx: &AppContext, target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
35    if target.is_absolute() {
36        Ok(target.to_path_buf())
37    } else if target == "." {
38        Ok(ctx.cwd.clone())
39    } else {
40        Err(AppError::Usage("target must be absolute or .".to_string()))
41    }
42}
43
44#[allow(
45    clippy::option_if_let_else,
46    reason = "the match states the two clock sources side by side"
47)]
48fn as_of(value: Option<&str>) -> Result<Date, AppError> {
49    match value {
50        None => Ok(today_utc()),
51        Some(text) => text
52            .parse::<Date>()
53            .map_err(|_| AppError::Usage(format!("--as-of is not an ISO date: {text}"))),
54    }
55}
56
57fn git_error(error: &GitError) -> AppError {
58    let code = match error {
59        GitError::Unsupported(_) => 64,
60        GitError::MissingGit | GitError::Transport(_) => 69,
61        GitError::Malformed => 65,
62        GitError::Timeout => 75,
63    };
64    AppError::Git {
65        message: error.to_string(),
66        code,
67    }
68}
69
70fn status(ctx: &AppContext, args: StatusArgs) -> Result<(), AppError> {
71    let StatusArgs {
72        target,
73        as_of: as_of_arg,
74        json,
75    } = args;
76    let target = resolve(ctx, &target)?;
77    let root = docs_root(&GateCtx::new(target.clone()));
78    let report = evaluate(&target, &root, as_of(as_of_arg.as_deref())?)?;
79
80    if let Some(fatal) = &report.fatal {
81        return Err(AppError::ManifestInvalid(fatal.detail.clone()));
82    }
83
84    if json {
85        let entries: Vec<serde_json::Value> = report
86            .entries
87            .iter()
88            .map(|a| {
89                let state = match a.freshness {
90                    Some(Freshness::Current) => "current",
91                    Some(Freshness::Overdue(_)) => "overdue",
92                    None => "invalid",
93                };
94                serde_json::json!({
95                    "id": a.entry.id,
96                    "state": state,
97                    "next_check": a.next_check.map(|d| d.to_string()),
98                    "revision": a.entry.source.as_ref().map(|s| s.revision.clone()),
99                    "revalidate": a.entry.revalidate,
100                })
101            })
102            .collect();
103        return output::json(&serde_json::json!({ "ok": true, "tracked": entries }));
104    }
105
106    for a in &report.entries {
107        let state = match a.freshness {
108            Some(Freshness::Current) => "current".to_string(),
109            Some(Freshness::Overdue(days)) => format!("overdue by {days} day(s)"),
110            None => "invalid date".to_string(),
111        };
112        let next = a
113            .next_check
114            .map_or_else(|| "-".to_string(), |d| d.to_string());
115        output::line(format!("{}: {state}; next check {next}", a.entry.id));
116        if let Some(source) = &a.entry.source {
117            output::line(format!(
118                "  pinned {} at {}",
119                source.reference, source.revision
120            ));
121        }
122        if !matches!(a.freshness, Some(Freshness::Current)) {
123            for step in &a.entry.revalidate {
124                output::line(format!("  revalidate: {step}"));
125            }
126        }
127    }
128    Ok(())
129}
130
131fn check(ctx: &AppContext, args: CheckArgs) -> Result<(), AppError> {
132    let target = resolve(ctx, &args.target)?;
133    let root = docs_root(&GateCtx::new(target.clone()));
134    let report = evaluate(&target, &root, today_utc())?;
135    if let Some(fatal) = &report.fatal {
136        return Err(AppError::ManifestInvalid(fatal.detail.clone()));
137    }
138
139    let mut results = Vec::new();
140    let mut moved = false;
141    let mut matched = false;
142    for a in &report.entries {
143        if args.id.as_deref().is_some_and(|id| id != a.entry.id) {
144            continue;
145        }
146        let Some(source) = &a.entry.source else {
147            continue;
148        };
149        matched = true;
150        let observed =
151            git::ls_remote(&source.repository, &source.reference).map_err(|e| git_error(&e))?;
152        let (state, observed_str) = match observed {
153            None => ("missing-reference", String::new()),
154            Some(sha) if sha == source.revision => ("current", sha),
155            Some(sha) => {
156                moved = true;
157                ("moved", sha)
158            }
159        };
160        results.push((
161            a.entry.id.clone(),
162            source.revision.clone(),
163            state,
164            observed_str,
165        ));
166    }
167
168    if args.id.is_some() && !matched {
169        return Err(AppError::Usage(format!(
170            "no tracked Git entry with id {}",
171            args.id.unwrap_or_default()
172        )));
173    }
174
175    if args.json {
176        let entries: Vec<serde_json::Value> = results
177            .iter()
178            .map(|(id, pinned, state, observed)| {
179                serde_json::json!({
180                    "id": id,
181                    "pinned": pinned,
182                    "state": state,
183                    "observed": if observed.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(observed.clone()) },
184                })
185            })
186            .collect();
187        output::json(&serde_json::json!({ "ok": true, "checked": entries }))?;
188    } else {
189        for (id, pinned, state, observed) in &results {
190            match *state {
191                "current" => output::line(format!("{id}: current at {pinned}")),
192                "moved" => output::line(format!("{id}: moved from {pinned} to {observed}")),
193                _ => output::line(format!(
194                    "{id}: reference missing upstream (pinned {pinned})"
195                )),
196            }
197        }
198    }
199
200    if args.fail_on_update && moved {
201        return Err(AppError::Violations {
202            count: results.iter().filter(|(_, _, s, _)| *s == "moved").count(),
203        });
204    }
205    Ok(())
206}