1use std::process::Command;
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use crate::domain::ownership::Sha256;
15use crate::domain::skill_record::{RECORD_PATH, SkillRecord};
16use crate::services::skill_installer::{AGENTS_ROOT, CLAUDE_ROOT, SHARED_ROOT, home};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22 Hard,
24 Soft,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32 Ok,
34 Failed,
36}
37
38#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41 pub id: &'static str,
43 pub class: ProbeClass,
45 pub status: ProbeStatus,
47 pub message: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56 Self {
57 id,
58 class,
59 status: ProbeStatus::Ok,
60 message: message.into(),
61 remediation: None,
62 }
63 }
64
65 fn failed(
66 id: &'static str,
67 class: ProbeClass,
68 message: impl Into<String>,
69 remediation: impl Into<String>,
70 ) -> Self {
71 Self {
72 id,
73 class,
74 status: ProbeStatus::Failed,
75 message: message.into(),
76 remediation: Some(remediation.into()),
77 }
78 }
79}
80
81pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
87
88#[must_use]
90pub fn run_all() -> Vec<ProbeResult> {
91 vec![
92 state_root(),
93 skill_roots(),
94 skill_gate(),
95 skill_payload(),
96 tool(
97 "git",
98 "SDD_GIT_BIN",
99 "git",
100 "git; retiring a migrated document is safe only where version control restores it",
101 &["--version"],
102 ),
103 tool(
104 "pre-commit",
105 "SDD_PRE_COMMIT_BIN",
106 "pre-commit",
107 "pre-commit; the delivered gates run through it",
108 &["--version"],
109 ),
110 ]
111}
112
113fn tool(
117 id: &'static str,
118 env_override: &str,
119 default_bin: &str,
120 label: &str,
121 args: &[&str],
122) -> ProbeResult {
123 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
124 match Command::new(&bin).args(args).output() {
125 Ok(out) if out.status.success() => {
126 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
127 }
128 Ok(_) => ProbeResult::failed(
129 id,
130 ProbeClass::Soft,
131 format!("{default_bin} does not answer {}", args.join(" ")),
132 format!("repair {label}"),
133 ),
134 Err(_) => ProbeResult::failed(
135 id,
136 ProbeClass::Soft,
137 format!("{default_bin} is not on PATH"),
138 format!("install {label}"),
139 ),
140 }
141}
142
143fn state_root() -> ProbeResult {
146 let id = "state-root";
147 let Ok(home) = home() else {
148 return ProbeResult::failed(
149 id,
150 ProbeClass::Hard,
151 "HOME is not set, so no state root resolves",
152 "export HOME",
153 );
154 };
155 let root = home.join(".local/state/spec-driven-docs");
156 let probe = root.join(format!(".probe-{}", std::process::id()));
157 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
158 let _ = std::fs::remove_file(&probe);
159 match written {
160 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{root} is writable")),
161 Err(source) => ProbeResult::failed(
162 id,
163 ProbeClass::Hard,
164 format!("{root} is not writable: {source}"),
165 format!("make {root} writable"),
166 ),
167 }
168}
169
170fn skill_roots() -> ProbeResult {
180 let id = SKILL_PROBES[0];
181 let Ok(home) = home() else {
182 return ProbeResult::failed(
183 id,
184 ProbeClass::Soft,
185 "HOME is not set, so no skill root resolves",
186 "export HOME",
187 );
188 };
189 let mut refused = Vec::new();
190 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
191 let root = home.join(root);
192 let Some(existing) = nearest_existing(&root) else {
193 refused.push(format!("no ancestor of {root} exists"));
194 continue;
195 };
196 if let Err(source) = accepts_a_write(&existing) {
197 refused.push(format!("{existing} is not writable: {source}"));
198 }
199 }
200 if refused.is_empty() {
201 ProbeResult::ok(
202 id,
203 ProbeClass::Soft,
204 format!("the skill roots under {home} accept writes"),
205 )
206 } else {
207 ProbeResult::failed(
208 id,
209 ProbeClass::Soft,
210 refused.join("; "),
211 format!("make the skill roots under {home} writable"),
212 )
213 }
214}
215
216fn skill_gate() -> ProbeResult {
226 let id = SKILL_PROBES[1];
227 let Ok(home) = home() else {
228 return ProbeResult::failed(
229 id,
230 ProbeClass::Soft,
231 "HOME is not set, so the shared root does not resolve",
232 "export HOME",
233 );
234 };
235 if let Some(link) = shared_chain_symlink(&home) {
236 return ProbeResult::failed(
237 id,
238 ProbeClass::Soft,
239 format!("the shared root is reached through a symlink: {link}"),
240 "remove the symlink; sdd skill install refuses to write through it",
241 );
242 }
243 let root = home.join(SHARED_ROOT);
244 let record = SkillRecord::load(&home.join(RECORD_PATH));
245 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::embedded::shared_artifacts()
246 .into_iter()
247 .map(|(path, bytes)| (root.join(path), bytes))
248 .collect();
249 let found = judge(planned, &record);
250 if let Some(first) = found.missing.first() {
251 return ProbeResult::failed(
255 id,
256 ProbeClass::Soft,
257 format!("a shared artifact every skill reads before acting is not installed: {first}"),
258 reinstall(found.all_recorded),
259 );
260 }
261 if !found.differing.is_empty() {
262 return ProbeResult::failed(
263 id,
264 ProbeClass::Soft,
265 format!(
266 "{} shared artifact(s) under {root} are not this binary's",
267 found.differing.len()
268 ),
269 reinstall(found.all_recorded),
270 );
271 }
272 ProbeResult::ok(
273 id,
274 ProbeClass::Soft,
275 format!("{root} holds this binary's shared artifacts"),
276 )
277}
278
279fn skill_payload() -> ProbeResult {
287 let id = SKILL_PROBES[2];
288 let Ok(home) = home() else {
289 return ProbeResult::failed(
290 id,
291 ProbeClass::Soft,
292 "HOME is not set, so no agent root resolves",
293 "export HOME",
294 );
295 };
296 let record = SkillRecord::load(&home.join(RECORD_PATH));
297 let mut planned = Vec::new();
298 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
299 let root = home.join(root);
300 if !root.is_dir() {
303 continue;
304 }
305 for name in crate::embedded::skill_names() {
306 let Some(text) = crate::embedded::skill(name) else {
307 return ProbeResult::failed(
308 id,
309 ProbeClass::Soft,
310 "this binary's embedded skills do not read",
311 "reinstall sdd; the payload it was built from is defective",
312 );
313 };
314 planned.push((root.join(name).join("SKILL.md"), text.as_bytes()));
315 }
316 }
317 if planned.is_empty() {
318 return ProbeResult::failed(
319 id,
320 ProbeClass::Soft,
321 format!("no agent skill root exists under {home}"),
322 "sdd skill install --apply",
323 );
324 }
325 let found = judge(planned, &record);
326 if let Some(first) = found.missing.first() {
327 return ProbeResult::failed(
330 id,
331 ProbeClass::Soft,
332 format!(
333 "{} of this binary's skills are not installed, the first at {first}",
334 found.missing.len()
335 ),
336 reinstall(found.all_recorded),
337 );
338 }
339 if !found.differing.is_empty() {
340 return ProbeResult::failed(
341 id,
342 ProbeClass::Soft,
343 format!(
344 "{} installed skill(s) are not this binary's; sdd is {}",
345 found.differing.len(),
346 env!("CARGO_PKG_VERSION")
347 ),
348 reinstall(found.all_recorded),
349 );
350 }
351 ProbeResult::ok(
352 id,
353 ProbeClass::Soft,
354 format!(
355 "{} installed skill destination(s) are this binary's",
356 found.matching
357 ),
358 )
359}
360
361struct Installed {
363 missing: Vec<Utf8PathBuf>,
365 differing: Vec<Utf8PathBuf>,
367 matching: usize,
369 all_recorded: bool,
373}
374
375fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &SkillRecord) -> Installed {
377 let mut found = Installed {
378 missing: Vec::new(),
379 differing: Vec::new(),
380 matching: 0,
381 all_recorded: true,
382 };
383 for (destination, bytes) in planned {
384 match std::fs::read(&destination) {
385 Ok(held) if held == bytes => found.matching += 1,
386 Ok(held) => {
387 if !record.wrote(&destination, &Sha256::of(&held)) {
388 found.all_recorded = false;
389 }
390 found.differing.push(destination);
391 }
392 Err(_) => found.missing.push(destination),
393 }
394 }
395 found
396}
397
398const fn reinstall(all_recorded: bool) -> &'static str {
402 if all_recorded {
403 "sdd skill install --apply"
404 } else {
405 "sdd skill install --apply --force"
406 }
407}
408
409fn shared_chain_symlink(home: &Utf8Path) -> Option<Utf8PathBuf> {
413 let record = home.join(RECORD_PATH);
414 let state_dir = record.parent()?;
415 let shared = home.join(SHARED_ROOT);
416 let mut current = Some(shared.as_path());
417 while let Some(dir) = current {
418 if !dir.starts_with(state_dir) {
419 break;
420 }
421 if dir.is_symlink() {
422 return Some(dir.to_owned());
423 }
424 current = dir.parent();
425 }
426 None
427}
428
429fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
432 let mut current = Some(path);
433 while let Some(dir) = current {
434 if dir.is_dir() {
435 return Some(dir.to_owned());
436 }
437 current = dir.parent();
438 }
439 None
440}
441
442fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
444 let probe = dir.join(format!(".sdd-probe-{}", std::process::id()));
445 let written = std::fs::write(&probe, b"probe");
446 let _ = std::fs::remove_file(&probe);
447 written
448}
449
450#[cfg(test)]
451mod tests {
452 #![allow(
453 clippy::unwrap_used,
454 reason = "a test panics as its failure signal, not as control flow"
455 )]
456
457 use super::*;
458
459 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
460 Utf8PathBuf::from(dir.path().to_str().unwrap())
461 }
462
463 #[test]
464 fn nearest_existing_walks_up_to_the_first_directory() {
465 let dir = tempfile::tempdir().unwrap();
466 let root = utf8(&dir);
467 assert_eq!(nearest_existing(&root).as_deref(), Some(root.as_path()));
468 assert_eq!(
469 nearest_existing(&root.join("a/b/c")).as_deref(),
470 Some(root.as_path())
471 );
472 }
473
474 #[test]
475 fn a_write_probe_leaves_nothing_behind() {
476 let dir = tempfile::tempdir().unwrap();
477 let root = utf8(&dir);
478 accepts_a_write(&root).unwrap();
479 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
480 }
481
482 #[test]
485 fn the_judge_tells_stale_bytes_from_the_users_own() {
486 let dir = tempfile::tempdir().unwrap();
487 let root = utf8(&dir);
488 let matching = root.join("matching.md");
489 let stale = root.join("stale.md");
490 let edited = root.join("edited.md");
491 let missing = root.join("missing.md");
492 std::fs::write(&matching, b"payload").unwrap();
493 std::fs::write(&stale, b"older release").unwrap();
494 std::fs::write(&edited, b"the user's own").unwrap();
495
496 let mut record = SkillRecord::new();
497 record
498 .written
499 .insert(stale.clone(), Sha256::of(b"older release"));
500
501 let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![
502 (matching, b"payload"),
503 (stale, b"payload"),
504 (missing.clone(), b"payload"),
505 ];
506 let found = judge(planned, &record);
507 assert_eq!(found.matching, 1);
508 assert_eq!(found.differing.len(), 1);
509 assert_eq!(found.missing, vec![missing]);
510 assert!(found.all_recorded, "the record vouches for the stale copy");
511
512 let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![(edited, b"payload")];
513 let found = judge(planned, &record);
514 assert!(
515 !found.all_recorded,
516 "bytes the record cannot account for are the user's"
517 );
518 }
519
520 #[test]
521 fn the_reinstall_needs_force_only_over_the_users_bytes() {
522 assert_eq!(reinstall(true), "sdd skill install --apply");
523 assert_eq!(reinstall(false), "sdd skill install --apply --force");
524 }
525}