spec_driven_docs/services/
installer.rs1use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{
14 CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION, validate_docs_scratch_path,
15};
16use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
17use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
18use crate::domain::profile::{ProfileId, resolve_destination};
19use crate::domain::version::CanonVersion;
20use crate::error::AppError;
21use crate::release::ReleaseBundle;
22use crate::services::hooks_render::{RenderOptions, render_block};
23
24#[derive(Debug, Clone)]
26pub struct InitOptions {
27 pub target: Utf8PathBuf,
29 pub profile: ProfileId,
31 pub apply: bool,
33 pub dry_run: bool,
35 pub docs_scratch: Option<Option<Utf8PathBuf>>,
38 pub reserve: Vec<String>,
41 pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
44}
45
46#[derive(Debug)]
48pub struct InitOutcome {
49 pub lines: Vec<String>,
51 pub applied: bool,
53 pub removed: Vec<String>,
55}
56
57fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
58 if !target.is_absolute() {
59 return Err(AppError::Usage("target must be absolute".to_string()));
60 }
61 if !target.is_dir() {
62 return Err(AppError::Usage(format!("unresolved target: {target}")));
63 }
64 let canonical = std::fs::canonicalize(target)?;
65 let canonical = Utf8PathBuf::from_path_buf(canonical)
66 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
67 if canonical.as_str().chars().all(|c| c == '/') {
68 return Err(AppError::Usage("refusing root target".to_string()));
69 }
70 let mut ancestor = Some(canonical.as_path());
71 while let Some(dir) = ancestor {
72 if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
73 && cargo.contains("name = \"spec-driven-docs\"")
74 {
75 return Err(AppError::Usage(
76 "target is inside the canon checkout".to_string(),
77 ));
78 }
79 ancestor = dir.parent();
80 }
81 Ok(canonical)
82}
83
84fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
85 for entry in target.read_dir_utf8()? {
86 let entry = entry?;
87 if entry.file_name() != ".git" {
88 return Ok(true);
89 }
90 }
91 Ok(false)
92}
93
94pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
100 std::fs::read_to_string(target.join(MANIFEST_PATH))
101 .ok()
102 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
103 .and_then(|value| value.get(key).cloned())
104 .filter(|value| !value.is_null())
105}
106
107fn installed_at(target: &Utf8Path) -> String {
108 recorded_field(target, "installed_at")
109 .and_then(|value| value.as_str().map(String::from))
110 .unwrap_or_else(|| {
111 jiff::Timestamp::now()
112 .strftime("%Y-%m-%dT%H:%M:%SZ")
113 .to_string()
114 })
115}
116
117pub(crate) fn resolved_docs_scratch(
129 target: &Utf8Path,
130 flag: Option<&Option<Utf8PathBuf>>,
131) -> Result<Option<Utf8PathBuf>, AppError> {
132 if let Some(declared) = flag {
133 return Ok(declared.clone());
134 }
135 let Some(recorded) = recorded_field(target, "docs_scratch") else {
136 return Ok(None);
137 };
138 let path = recorded
139 .as_str()
140 .filter(|path| !path.is_empty())
141 .map(Utf8PathBuf::from)
142 .ok_or_else(|| {
143 AppError::ManifestInvalid(format!(
144 "the recorded docs_scratch is not a path ({recorded}); \
145 re-declare it with --docs-scratch"
146 ))
147 })?;
148 if let Err(error) = validate_docs_scratch_path(&path) {
149 return Err(AppError::ManifestInvalid(format!(
150 "the recorded docs_scratch is not usable ({error}); \
151 re-declare it with --docs-scratch"
152 )));
153 }
154 Ok(Some(path))
155}
156
157#[derive(Debug, Clone)]
163pub struct TargetState {
164 pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
166 pub lines: Vec<String>,
168}
169
170#[allow(
171 clippy::too_many_lines,
172 reason = "computing the target state is one ordered pass the installer replays"
173)]
174pub fn compute_target_state(
181 target: &Utf8Path,
182 options: &InitOptions,
183 bundle: &dyn ReleaseBundle,
184) -> Result<TargetState, AppError> {
185 let profile = options.profile;
186 let landed: CanonVersion = bundle
191 .manifest()?
192 .version
193 .to_string()
194 .parse()
195 .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
196 let released = bundle.declaration()?;
197 let declaration = released.profile(profile).ok_or_else(|| {
198 AppError::Refused(format!(
199 "the release declares no {profile} profile, so it cannot land one"
200 ))
201 })?;
202 let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
203 let mut lines = Vec::new();
204 let mut managed_entries = Vec::new();
205 let mut adopted_entries = Vec::new();
206
207 for projection in declaration.managed {
208 let bytes = bundle.artifact(&projection.source)?;
209 let destination = Utf8PathBuf::from(&projection.destination);
210 managed_entries.push(ManagedEntry {
211 source: projection.source.clone().into(),
212 destination: destination.clone(),
213 sha256: Sha256::of(&bytes),
214 });
215 lines.push(destination.to_string());
216 files.push((destination, bytes));
217 }
218
219 let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
224 .and_then(|value| {
225 value.as_array().map(|entries| {
226 entries
227 .iter()
228 .filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
229 .collect()
230 })
231 })
232 .unwrap_or_default();
233 for projection in declaration.adopted {
234 let seed = bundle.artifact(&projection.source)?;
235 let destination = resolve_destination(&projection.destination, declaration.docs_root);
236 let existing = target.join(&destination);
237 let mut bytes = if existing.is_file() {
238 let held = std::fs::read(&existing)?;
239 if held != seed && !recorded_adopted.iter().any(|d| d == destination.as_str()) {
240 lines.push(format!(
241 "note: {destination} already exists and is kept; the seed was not written, so read it with 'sdd spec' and reconcile by hand"
242 ));
243 }
244 held
245 } else {
246 seed.clone()
247 };
248 if destination == crate::domain::instance_config::CONFIG_PATH
251 && let Ok(text) = std::str::from_utf8(&bytes)
252 {
253 let mut text = text.to_string();
254 if !options.reserve.is_empty() {
255 text = crate::domain::instance_config::with_reserved(&text, &options.reserve);
256 }
257 if let Some(selection) = &options.writing_style {
258 text = crate::domain::instance_config::with_writing_style(&text, selection);
259 }
260 bytes = text.into_bytes();
261 }
262 adopted_entries.push(AdoptedEntry {
263 source: projection.source.clone().into(),
264 destination: destination.clone(),
265 sha256: Sha256::of(&bytes),
266 baseline_sha256: Sha256::of(&seed),
267 });
268 lines.push(destination.to_string());
269 files.push((destination, bytes));
270 }
271
272 let config_path = target.join(HOOKS_CONFIG_PATH);
273 let host = if config_path.is_file() {
274 std::fs::read_to_string(&config_path)?
275 } else {
276 "repos:\n".to_string()
277 };
278 let (base, _) = crate::domain::marker::split_block(&host)?;
279 let indent = crate::domain::marker::splice_indent(&base)?;
280 let declared = files
284 .iter()
285 .find(|(destination, _)| destination == crate::domain::instance_config::CONFIG_PATH)
286 .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
287 .map(crate::domain::instance_config::InstanceConfig::parse)
288 .transpose()
289 .map_err(|error| anyhow::anyhow!("{error}"))?
290 .unwrap_or_default();
291 let writing_style = declared.writing_style.clone();
292 let block = render_block(&RenderOptions {
293 docs_root: declaration.docs_root.to_string(),
294 indent,
295 declaration: declared,
296 ..RenderOptions::default()
297 });
298 let spliced = crate::domain::marker::splice(&base, &block)?;
299 let marker_hash = crate::domain::marker::block_hash(&spliced)
300 .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
301 lines.push(HOOKS_CONFIG_PATH.to_string());
302 files.push((Utf8PathBuf::from(HOOKS_CONFIG_PATH), spliced.into_bytes()));
303
304 let mut integration_blocks = vec![IntegrationBlock {
305 path: HOOKS_CONFIG_PATH.into(),
306 marker_hash,
307 }];
308
309 let agents_relative = Utf8Path::new(AGENTS_DIGEST_PATH);
313 if target.join(agents_relative).is_symlink() {
314 return Err(AppError::Refused(
315 "AGENTS.md is a symlink; refusing to write the documentation block through it"
316 .to_string(),
317 ));
318 }
319 let agents_host = if target.join(agents_relative).is_file() {
320 std::fs::read_to_string(target.join(agents_relative))?
321 } else {
322 String::new()
323 };
324 let agents_block = crate::services::agents_render::render_block(
325 &declaration.docs_root.to_string(),
326 &writing_style,
327 );
328 let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
329 let agents_hash = crate::domain::marker::block_hash_with(
330 &agents,
331 crate::domain::marker::AGENTS_BEGIN,
332 crate::domain::marker::AGENTS_END,
333 )
334 .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
335 if agents_host.contains("## Documentation")
338 && crate::domain::marker::block_region_with(
339 &agents_host,
340 crate::domain::marker::AGENTS_BEGIN,
341 crate::domain::marker::AGENTS_END,
342 )
343 .is_none()
344 {
345 lines.push(
346 "note: AGENTS.md carries an unmarked '## Documentation' section; the managed block was appended and the old section left in place — remove it by hand".to_string(),
347 );
348 }
349 lines.push(AGENTS_DIGEST_PATH.to_string());
350 files.push((agents_relative.to_path_buf(), agents.into_bytes()));
351 integration_blocks.push(IntegrationBlock {
352 path: AGENTS_DIGEST_PATH.into(),
353 marker_hash: agents_hash,
354 });
355
356 let manifest = Manifest {
357 schema_version: SCHEMA_VERSION,
358 canon_version: landed,
359 canon_source: CANON_SOURCE.to_string(),
360 profile,
361 docs_root: declaration.docs_root,
362 installed_at: installed_at(target),
363 docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
364 managed_files: managed_entries,
365 adopted_files: adopted_entries,
366 integration_blocks,
367 };
368 lines.push(MANIFEST_PATH.to_string());
369 files.push((
370 Utf8PathBuf::from(MANIFEST_PATH),
371 manifest.to_json().into_bytes(),
372 ));
373
374 Ok(TargetState { files, lines })
375}
376
377pub fn init(
386 options: &InitOptions,
387 bundle: &dyn ReleaseBundle,
388 intent: crate::plan::classify::Intent,
389) -> Result<InitOutcome, AppError> {
390 init_with(
391 &crate::plan::decision::Selections::new(),
392 options,
393 bundle,
394 intent,
395 )
396}
397
398pub fn init_with(
405 answered: &crate::plan::decision::Selections,
406 options: &InitOptions,
407 bundle: &dyn ReleaseBundle,
408 intent: crate::plan::classify::Intent,
409) -> Result<InitOutcome, AppError> {
410 let target = canonical_target(&options.target)?;
411 crate::commands::front::serves(intent, &target)?;
415 let forced_dry = !options.apply
416 && !options.dry_run
417 && target_has_content(&target)?
418 && !target.join(MANIFEST_PATH).is_file();
419 let dry = options.dry_run || forced_dry;
420
421 let state = compute_target_state(&target, options, bundle)?;
422 let mut lines = state.lines;
423
424 let landing = crate::plan::session::Landing {
425 target: &target,
426 release: crate::plan::session::ReleaseRef::of(bundle)?,
427 offline: true,
428 selections: answered.clone(),
429 carried: profile_only(options.profile),
430 reserve: options.reserve.clone(),
431 declared: Some(options.clone()),
432 };
433
434 if dry {
435 if forced_dry {
436 lines.push(
437 "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
438 .to_string(),
439 );
440 }
441 lines.extend(crate::plan::session::preview_lines(
445 &crate::plan::session::preview(&landing)?,
446 ));
447 lines.push("DRY RUN: no files written".to_string());
448 return Ok(InitOutcome {
449 lines,
450 applied: false,
451 removed: Vec::new(),
452 });
453 }
454
455 let result = crate::plan::session::land(&landing)?;
461 for refused in result
462 .postconditions
463 .iter()
464 .filter(|postcondition| !postcondition.held)
465 {
466 lines.push(format!(
467 "FAIL {} did not hold: {}",
468 refused.id,
469 refused.detail.clone().unwrap_or_default()
470 ));
471 }
472 Ok(InitOutcome {
473 lines,
474 applied: true,
475 removed: result
476 .operations
477 .iter()
478 .filter(|operation| operation.kind == "remove-owned-file")
479 .map(|operation| operation.path.clone())
480 .collect(),
481 })
482}
483
484fn profile_only(profile: ProfileId) -> crate::plan::decision::Selections {
491 let mut selections = crate::plan::decision::Selections::new();
492 selections.insert(
493 crate::plan::decision::id::PROFILE.to_string(),
494 profile.to_string(),
495 );
496 selections
497}