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(clippy::option_if_let_else)]
45fn as_of(value: Option<&str>) -> Result<Date, AppError> {
46    match value {
47        None => Ok(today_utc()),
48        Some(text) => text
49            .parse::<Date>()
50            .map_err(|_| AppError::Usage(format!("--as-of is not an ISO date: {text}"))),
51    }
52}
53
54fn git_error(error: &GitError) -> AppError {
55    let code = match error {
56        GitError::Unsupported(_) => 64,
57        GitError::MissingGit | GitError::Transport(_) => 69,
58        GitError::Malformed => 65,
59        GitError::Timeout => 75,
60    };
61    AppError::Git {
62        message: error.to_string(),
63        code,
64    }
65}
66
67fn status(ctx: &AppContext, args: StatusArgs) -> Result<(), AppError> {
68    let StatusArgs {
69        target,
70        as_of: as_of_arg,
71        json,
72    } = args;
73    let target = resolve(ctx, &target)?;
74    let root = docs_root(&GateCtx::new(target.clone()));
75    let report = evaluate(&target, &root, as_of(as_of_arg.as_deref())?)?;
76
77    if let Some(fatal) = &report.fatal {
78        return Err(AppError::ManifestInvalid(fatal.detail.clone()));
79    }
80
81    if json {
82        let entries: Vec<serde_json::Value> = report
83            .entries
84            .iter()
85            .map(|a| {
86                let state = match a.freshness {
87                    Some(Freshness::Current) => "current",
88                    Some(Freshness::Overdue(_)) => "overdue",
89                    None => "invalid",
90                };
91                serde_json::json!({
92                    "id": a.entry.id,
93                    "state": state,
94                    "next_check": a.next_check.map(|d| d.to_string()),
95                    "revision": a.entry.source.as_ref().map(|s| s.revision.clone()),
96                    "revalidate": a.entry.revalidate,
97                })
98            })
99            .collect();
100        return output::json(&serde_json::json!({ "ok": true, "tracked": entries }));
101    }
102
103    for a in &report.entries {
104        let state = match a.freshness {
105            Some(Freshness::Current) => "current".to_string(),
106            Some(Freshness::Overdue(days)) => format!("overdue by {days} day(s)"),
107            None => "invalid date".to_string(),
108        };
109        let next = a
110            .next_check
111            .map_or_else(|| "-".to_string(), |d| d.to_string());
112        output::line(format!("{}: {state}; next check {next}", a.entry.id));
113        if let Some(source) = &a.entry.source {
114            output::line(format!(
115                "  pinned {} at {}",
116                source.reference, source.revision
117            ));
118        }
119        if !matches!(a.freshness, Some(Freshness::Current)) {
120            for step in &a.entry.revalidate {
121                output::line(format!("  revalidate: {step}"));
122            }
123        }
124    }
125    Ok(())
126}
127
128fn check(ctx: &AppContext, args: CheckArgs) -> Result<(), AppError> {
129    let target = resolve(ctx, &args.target)?;
130    let root = docs_root(&GateCtx::new(target.clone()));
131    let report = evaluate(&target, &root, today_utc())?;
132    if let Some(fatal) = &report.fatal {
133        return Err(AppError::ManifestInvalid(fatal.detail.clone()));
134    }
135
136    let mut results = Vec::new();
137    let mut moved = false;
138    let mut matched = false;
139    for a in &report.entries {
140        if args.id.as_deref().is_some_and(|id| id != a.entry.id) {
141            continue;
142        }
143        let Some(source) = &a.entry.source else {
144            continue;
145        };
146        matched = true;
147        let observed =
148            git::ls_remote(&source.repository, &source.reference).map_err(|e| git_error(&e))?;
149        let (state, observed_str) = match observed {
150            None => ("missing-reference", String::new()),
151            Some(sha) if sha == source.revision => ("current", sha),
152            Some(sha) => {
153                moved = true;
154                ("moved", sha)
155            }
156        };
157        results.push((
158            a.entry.id.clone(),
159            source.revision.clone(),
160            state,
161            observed_str,
162        ));
163    }
164
165    if args.id.is_some() && !matched {
166        return Err(AppError::Usage(format!(
167            "no tracked Git entry with id {}",
168            args.id.unwrap_or_default()
169        )));
170    }
171
172    if args.json {
173        let entries: Vec<serde_json::Value> = results
174            .iter()
175            .map(|(id, pinned, state, observed)| {
176                serde_json::json!({
177                    "id": id,
178                    "pinned": pinned,
179                    "state": state,
180                    "observed": if observed.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(observed.clone()) },
181                })
182            })
183            .collect();
184        output::json(&serde_json::json!({ "ok": true, "checked": entries }))?;
185    } else {
186        for (id, pinned, state, observed) in &results {
187            match *state {
188                "current" => output::line(format!("{id}: current at {pinned}")),
189                "moved" => output::line(format!("{id}: moved from {pinned} to {observed}")),
190                _ => output::line(format!(
191                    "{id}: reference missing upstream (pinned {pinned})"
192                )),
193            }
194        }
195    }
196
197    if args.fail_on_update && moved {
198        return Err(AppError::Violations {
199            count: results.iter().filter(|(_, _, s, _)| *s == "moved").count(),
200        });
201    }
202    Ok(())
203}