use std::process::Command;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::domain::ownership::Sha256;
use crate::domain::skill_record::{RECORD_PATH, SkillRecord};
use crate::services::skill_installer::{AGENTS_ROOT, CLAUDE_ROOT, SHARED_ROOT, home};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProbeClass {
Hard,
Soft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProbeStatus {
Ok,
Failed,
}
#[derive(Debug, Serialize)]
pub struct ProbeResult {
pub id: &'static str,
pub class: ProbeClass,
pub status: ProbeStatus,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
impl ProbeResult {
fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
Self {
id,
class,
status: ProbeStatus::Ok,
message: message.into(),
remediation: None,
}
}
fn failed(
id: &'static str,
class: ProbeClass,
message: impl Into<String>,
remediation: impl Into<String>,
) -> Self {
Self {
id,
class,
status: ProbeStatus::Failed,
message: message.into(),
remediation: Some(remediation.into()),
}
}
}
pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
#[must_use]
pub fn run_all() -> Vec<ProbeResult> {
vec![
state_root(),
skill_roots(),
skill_gate(),
skill_payload(),
tool(
"git",
"SDD_GIT_BIN",
"git",
"git; retiring a migrated document is safe only where version control restores it",
&["--version"],
),
tool(
"pre-commit",
"SDD_PRE_COMMIT_BIN",
"pre-commit",
"pre-commit; the delivered gates run through it",
&["--version"],
),
]
}
fn tool(
id: &'static str,
env_override: &str,
default_bin: &str,
label: &str,
args: &[&str],
) -> ProbeResult {
let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
match Command::new(&bin).args(args).output() {
Ok(out) if out.status.success() => {
ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
}
Ok(_) => ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} does not answer {}", args.join(" ")),
format!("repair {label}"),
),
Err(_) => ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} is not on PATH"),
format!("install {label}"),
),
}
}
fn state_root() -> ProbeResult {
let id = "state-root";
let Ok(home) = home() else {
return ProbeResult::failed(
id,
ProbeClass::Hard,
"HOME is not set, so no state root resolves",
"export HOME",
);
};
let root = home.join(".local/state/spec-driven-docs");
let probe = root.join(format!(".probe-{}", std::process::id()));
let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
let _ = std::fs::remove_file(&probe);
match written {
Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{root} is writable")),
Err(source) => ProbeResult::failed(
id,
ProbeClass::Hard,
format!("{root} is not writable: {source}"),
format!("make {root} writable"),
),
}
}
fn skill_roots() -> ProbeResult {
let id = SKILL_PROBES[0];
let Ok(home) = home() else {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"HOME is not set, so no skill root resolves",
"export HOME",
);
};
let mut refused = Vec::new();
for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
let root = home.join(root);
let Some(existing) = nearest_existing(&root) else {
refused.push(format!("no ancestor of {root} exists"));
continue;
};
if let Err(source) = accepts_a_write(&existing) {
refused.push(format!("{existing} is not writable: {source}"));
}
}
if refused.is_empty() {
ProbeResult::ok(
id,
ProbeClass::Soft,
format!("the skill roots under {home} accept writes"),
)
} else {
ProbeResult::failed(
id,
ProbeClass::Soft,
refused.join("; "),
format!("make the skill roots under {home} writable"),
)
}
}
fn skill_gate() -> ProbeResult {
let id = SKILL_PROBES[1];
let Ok(home) = home() else {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"HOME is not set, so the shared root does not resolve",
"export HOME",
);
};
if let Some(link) = shared_chain_symlink(&home) {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("the shared root is reached through a symlink: {link}"),
"remove the symlink; sdd skill install refuses to write through it",
);
}
let root = home.join(SHARED_ROOT);
let record = SkillRecord::load(&home.join(RECORD_PATH));
let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::embedded::shared_artifacts()
.into_iter()
.map(|(path, bytes)| (root.join(path), bytes))
.collect();
let found = judge(planned, &record);
if let Some(first) = found.missing.first() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("a shared artifact every skill reads before acting is not installed: {first}"),
reinstall(found.all_recorded),
);
}
if !found.differing.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} shared artifact(s) under {root} are not this binary's",
found.differing.len()
),
reinstall(found.all_recorded),
);
}
ProbeResult::ok(
id,
ProbeClass::Soft,
format!("{root} holds this binary's shared artifacts"),
)
}
fn skill_payload() -> ProbeResult {
let id = SKILL_PROBES[2];
let Ok(home) = home() else {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"HOME is not set, so no agent root resolves",
"export HOME",
);
};
let record = SkillRecord::load(&home.join(RECORD_PATH));
let mut planned = Vec::new();
for root in [CLAUDE_ROOT, AGENTS_ROOT] {
let root = home.join(root);
if !root.is_dir() {
continue;
}
for name in crate::embedded::skill_names() {
let Some(text) = crate::embedded::skill(name) else {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"this binary's embedded skills do not read",
"reinstall sdd; the payload it was built from is defective",
);
};
planned.push((root.join(name).join("SKILL.md"), text.as_bytes()));
}
}
if planned.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("no agent skill root exists under {home}"),
"sdd skill install --apply",
);
}
let found = judge(planned, &record);
if let Some(first) = found.missing.first() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} of this binary's skills are not installed, the first at {first}",
found.missing.len()
),
reinstall(found.all_recorded),
);
}
if !found.differing.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} installed skill(s) are not this binary's; sdd is {}",
found.differing.len(),
env!("CARGO_PKG_VERSION")
),
reinstall(found.all_recorded),
);
}
ProbeResult::ok(
id,
ProbeClass::Soft,
format!(
"{} installed skill destination(s) are this binary's",
found.matching
),
)
}
struct Installed {
missing: Vec<Utf8PathBuf>,
differing: Vec<Utf8PathBuf>,
matching: usize,
all_recorded: bool,
}
fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &SkillRecord) -> Installed {
let mut found = Installed {
missing: Vec::new(),
differing: Vec::new(),
matching: 0,
all_recorded: true,
};
for (destination, bytes) in planned {
match std::fs::read(&destination) {
Ok(held) if held == bytes => found.matching += 1,
Ok(held) => {
if !record.wrote(&destination, &Sha256::of(&held)) {
found.all_recorded = false;
}
found.differing.push(destination);
}
Err(_) => found.missing.push(destination),
}
}
found
}
const fn reinstall(all_recorded: bool) -> &'static str {
if all_recorded {
"sdd skill install --apply"
} else {
"sdd skill install --apply --force"
}
}
fn shared_chain_symlink(home: &Utf8Path) -> Option<Utf8PathBuf> {
let record = home.join(RECORD_PATH);
let state_dir = record.parent()?;
let shared = home.join(SHARED_ROOT);
let mut current = Some(shared.as_path());
while let Some(dir) = current {
if !dir.starts_with(state_dir) {
break;
}
if dir.is_symlink() {
return Some(dir.to_owned());
}
current = dir.parent();
}
None
}
fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
let mut current = Some(path);
while let Some(dir) = current {
if dir.is_dir() {
return Some(dir.to_owned());
}
current = dir.parent();
}
None
}
fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
let probe = dir.join(format!(".sdd-probe-{}", std::process::id()));
let written = std::fs::write(&probe, b"probe");
let _ = std::fs::remove_file(&probe);
written
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from(dir.path().to_str().unwrap())
}
#[test]
fn nearest_existing_walks_up_to_the_first_directory() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
assert_eq!(nearest_existing(&root).as_deref(), Some(root.as_path()));
assert_eq!(
nearest_existing(&root.join("a/b/c")).as_deref(),
Some(root.as_path())
);
}
#[test]
fn a_write_probe_leaves_nothing_behind() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
accepts_a_write(&root).unwrap();
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn the_judge_tells_stale_bytes_from_the_users_own() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
let matching = root.join("matching.md");
let stale = root.join("stale.md");
let edited = root.join("edited.md");
let missing = root.join("missing.md");
std::fs::write(&matching, b"payload").unwrap();
std::fs::write(&stale, b"older release").unwrap();
std::fs::write(&edited, b"the user's own").unwrap();
let mut record = SkillRecord::new();
record
.written
.insert(stale.clone(), Sha256::of(b"older release"));
let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![
(matching, b"payload"),
(stale, b"payload"),
(missing.clone(), b"payload"),
];
let found = judge(planned, &record);
assert_eq!(found.matching, 1);
assert_eq!(found.differing.len(), 1);
assert_eq!(found.missing, vec![missing]);
assert!(found.all_recorded, "the record vouches for the stale copy");
let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![(edited, b"payload")];
let found = judge(planned, &record);
assert!(
!found.all_recorded,
"bytes the record cannot account for are the user's"
);
}
#[test]
fn the_reinstall_needs_force_only_over_the_users_bytes() {
assert_eq!(reinstall(true), "sdd skill install --apply");
assert_eq!(reinstall(false), "sdd skill install --apply --force");
}
}