spec_driven_docs/services/
installer.rs1use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{MANIFEST_PATH, validate_docs_scratch_path};
14use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
15use crate::domain::profile::{ProfileId, resolve_destination};
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18
19#[derive(Debug, Clone)]
21pub struct InitOptions {
22 pub target: Utf8PathBuf,
24 pub profile: ProfileId,
26 pub apply: bool,
28 pub dry_run: bool,
30 pub docs_scratch: Option<Option<Utf8PathBuf>>,
33 pub reserve: Vec<String>,
36 pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
39}
40
41#[derive(Debug)]
43pub struct InitOutcome {
44 pub lines: Vec<String>,
46 pub applied: bool,
48 pub removed: Vec<String>,
50}
51
52fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
53 if !target.is_absolute() {
54 return Err(AppError::Usage("target must be absolute".to_string()));
55 }
56 if !target.is_dir() {
57 return Err(AppError::Usage(format!("unresolved target: {target}")));
58 }
59 let canonical = std::fs::canonicalize(target)?;
60 let canonical = Utf8PathBuf::from_path_buf(canonical)
61 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
62 if canonical.as_str().chars().all(|c| c == '/') {
63 return Err(AppError::Usage("refusing root target".to_string()));
64 }
65 let mut ancestor = Some(canonical.as_path());
66 while let Some(dir) = ancestor {
67 if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
68 && cargo.contains("name = \"spec-driven-docs\"")
69 {
70 return Err(AppError::Usage(
71 "target is inside the canon checkout".to_string(),
72 ));
73 }
74 ancestor = dir.parent();
75 }
76 Ok(canonical)
77}
78
79fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
80 for entry in target.read_dir_utf8()? {
81 let entry = entry?;
82 if entry.file_name() != ".git" {
83 return Ok(true);
84 }
85 }
86 Ok(false)
87}
88
89pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
95 std::fs::read_to_string(target.join(MANIFEST_PATH))
96 .ok()
97 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
98 .and_then(|value| value.get(key).cloned())
99 .filter(|value| !value.is_null())
100}
101
102fn installed_at(target: &Utf8Path) -> String {
103 recorded_field(target, "installed_at")
104 .and_then(|value| value.as_str().map(String::from))
105 .unwrap_or_else(|| {
106 jiff::Timestamp::now()
107 .strftime("%Y-%m-%dT%H:%M:%SZ")
108 .to_string()
109 })
110}
111
112pub(crate) fn resolved_docs_scratch(
124 target: &Utf8Path,
125 flag: Option<&Option<Utf8PathBuf>>,
126) -> Result<Option<Utf8PathBuf>, AppError> {
127 if let Some(declared) = flag {
128 return Ok(declared.clone());
129 }
130 let Some(recorded) = recorded_field(target, "docs_scratch") else {
131 return Ok(None);
132 };
133 let path = recorded
134 .as_str()
135 .filter(|path| !path.is_empty())
136 .map(Utf8PathBuf::from)
137 .ok_or_else(|| {
138 AppError::ManifestInvalid(format!(
139 "the recorded docs_scratch is not a path ({recorded}); \
140 re-declare it with --docs-scratch"
141 ))
142 })?;
143 if let Err(error) = validate_docs_scratch_path(&path) {
144 return Err(AppError::ManifestInvalid(format!(
145 "the recorded docs_scratch is not usable ({error}); \
146 re-declare it with --docs-scratch"
147 )));
148 }
149 Ok(Some(path))
150}
151
152#[derive(Debug, Clone)]
158pub struct TargetState {
159 pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
161 pub lines: Vec<String>,
163}
164
165pub fn compute_target_state(
178 target: &Utf8Path,
179 options: &InitOptions,
180) -> Result<TargetState, AppError> {
181 let candidate = candidate_for(target, options)?;
182 let mut lines: Vec<String> = Vec::new();
183 for destination in &candidate.destinations {
184 lines.push(destination.path.to_string());
185 }
186 lines.extend(candidate.notes.iter().cloned());
187 lines.push(MANIFEST_PATH.to_string());
188 Ok(TargetState {
189 files: candidate.files(),
190 lines,
191 })
192}
193
194pub fn candidate_for(
200 target: &Utf8Path,
201 options: &InitOptions,
202) -> Result<crate::candidate::Candidate, AppError> {
203 crate::candidate::project(&gather(target, options)?)
204}
205
206pub fn resolved_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
213 canonical_target(target)
214}
215
216fn gather(target: &Utf8Path, options: &InitOptions) -> Result<crate::candidate::Input, AppError> {
218 let docs_root = crate::candidate::docs_root_of(options.profile)?;
219
220 let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
225 .and_then(|value| {
226 value.as_array().map(|entries| {
227 entries
228 .iter()
229 .filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
230 .collect()
231 })
232 })
233 .unwrap_or_default();
234
235 let mut existing = std::collections::BTreeMap::new();
236 for projection in &crate::domain::profile::DECLARATION.adopted {
237 let destination = resolve_destination(&projection.destination, docs_root);
238 let path = target.join(&destination);
239 if path.is_file() {
240 existing.insert(destination, std::fs::read(&path)?);
241 }
242 }
243
244 let hooks_path = target.join(HOOKS_CONFIG_PATH);
245 let hooks_host = if hooks_path.is_file() {
246 std::fs::read_to_string(&hooks_path)?
247 } else {
248 String::new()
249 };
250
251 let agents_path = target.join(AGENTS_DIGEST_PATH);
255 if agents_path.is_symlink() {
256 return Err(AppError::Refused(
257 "AGENTS.md is a symlink; refusing to write the documentation block through it"
258 .to_string(),
259 ));
260 }
261 let agents_host = if agents_path.is_file() {
262 std::fs::read_to_string(&agents_path)?
263 } else {
264 String::new()
265 };
266
267 Ok(crate::candidate::Input {
268 profile: options.profile,
269 version: CanonVersion::current(),
270 installed_at: installed_at(target),
271 docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
272 reserve: options.reserve.clone(),
273 writing_style: options.writing_style.clone(),
274 evidence: crate::candidate::Evidence {
275 existing,
276 recorded_adopted,
277 hooks_host,
278 agents_host,
279 },
280 })
281}
282
283pub fn init(
292 options: &InitOptions,
293 intent: crate::landing::classify::Intent,
294) -> Result<InitOutcome, AppError> {
295 init_holding(None, options, intent)
296}
297
298pub fn init_holding(
307 held: Option<crate::transaction::lock::Lock>,
308 options: &InitOptions,
309 intent: crate::landing::classify::Intent,
310) -> Result<InitOutcome, AppError> {
311 let target = canonical_target(&options.target)?;
312 crate::commands::front::serves(intent, &target)?;
316 let forced_dry = !options.apply
317 && !options.dry_run
318 && target_has_content(&target)?
319 && !target.join(MANIFEST_PATH).is_file();
320 let dry = options.dry_run || forced_dry;
321
322 let held = match (dry, held) {
328 (true, _) => None,
329 (false, Some(held)) => Some(held),
330 (false, None) => Some(crate::landing::lock::hold(&target)?),
331 };
332
333 if let Some(recorded) = recorded_field(&target, "profile").and_then(|value| {
338 ProfileId::every().find(|profile| Some(profile.as_str()) == value.as_str())
339 }) && recorded != options.profile
340 {
341 return Err(AppError::Refused(format!(
342 "{target} records the {recorded} profile and this run asks for {}; \
343 moving a profile moves the documentation root, which is a migration to ask for deliberately",
344 options.profile
345 )));
346 }
347
348 let candidate = candidate_for(&target, options)?;
349 let mut lines: Vec<String> = candidate
350 .destinations
351 .iter()
352 .map(|destination| destination.path.to_string())
353 .collect();
354 lines.extend(candidate.notes.iter().cloned());
355 lines.push(MANIFEST_PATH.to_string());
356
357 if dry {
358 if forced_dry {
359 lines.push(
360 "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
361 .to_string(),
362 );
363 }
364 lines.push("DRY RUN: no files written".to_string());
365 return Ok(InitOutcome {
366 lines,
367 applied: false,
368 removed: Vec::new(),
369 });
370 }
371
372 let outcome = crate::landing::apply::land(&target, &candidate, &recorded(&target))?;
373 drop(held);
374 Ok(InitOutcome {
375 lines,
376 applied: true,
377 removed: outcome.removed,
378 })
379}
380
381pub(crate) fn recorded(target: &Utf8Path) -> crate::landing::apply::Recorded {
387 crate::landing::apply::Recorded {
388 managed: digests(target, "managed_files", "destination", "sha256"),
389 integration: digests(target, "integration_blocks", "path", "marker_hash"),
390 }
391}
392
393fn digests(
395 target: &Utf8Path,
396 key: &str,
397 name: &str,
398 digest: &str,
399) -> Vec<(String, crate::domain::ownership::Sha256)> {
400 let Some(value) = recorded_field(target, key) else {
401 return Vec::new();
402 };
403 let Some(entries) = value.as_array() else {
404 return Vec::new();
405 };
406 entries
407 .iter()
408 .filter_map(|entry| {
409 let destination = entry.get(name)?.as_str()?.to_string();
410 let held = entry.get(digest)?.as_str()?;
411 let held = held.parse::<crate::domain::ownership::Sha256>().ok()?;
412 Some((destination, held))
413 })
414 .collect()
415}