spec_driven_docs/commands/
track.rs1use 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
21pub 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}