spec_driven_docs/services/
installer.rs1use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{
14 CANON_SOURCE, MANIFEST_PATH, Manifest, PlanZone, SCHEMA_VERSION, validate_docs_scratch_path,
15 validate_plan_zone_path,
16};
17use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
18use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
19use crate::domain::profile::{ProfileId, resolve_destination};
20use crate::domain::version::CanonVersion;
21use crate::error::AppError;
22use crate::release::ReleaseBundle;
23use crate::services::hooks_render::{RenderOptions, render_block};
24
25#[derive(Debug, Clone)]
27pub struct InitOptions {
28 pub target: Utf8PathBuf,
30 pub profile: ProfileId,
32 pub apply: bool,
34 pub dry_run: bool,
36 pub plan_zone: Option<PlanZone>,
38 pub docs_scratch: Option<Option<Utf8PathBuf>>,
41 pub reserve: Vec<String>,
44 pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
47}
48
49#[derive(Debug)]
51pub struct InitOutcome {
52 pub lines: Vec<String>,
54 pub applied: bool,
56 pub removed: Vec<String>,
58}
59
60fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
61 if !target.is_absolute() {
62 return Err(AppError::Usage("target must be absolute".to_string()));
63 }
64 if !target.is_dir() {
65 return Err(AppError::Usage(format!("unresolved target: {target}")));
66 }
67 let canonical = std::fs::canonicalize(target)?;
68 let canonical = Utf8PathBuf::from_path_buf(canonical)
69 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
70 if canonical.as_str().chars().all(|c| c == '/') {
71 return Err(AppError::Usage("refusing root target".to_string()));
72 }
73 let mut ancestor = Some(canonical.as_path());
74 while let Some(dir) = ancestor {
75 if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
76 && cargo.contains("name = \"spec-driven-docs\"")
77 {
78 return Err(AppError::Usage(
79 "target is inside the canon checkout".to_string(),
80 ));
81 }
82 ancestor = dir.parent();
83 }
84 Ok(canonical)
85}
86
87fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
88 for entry in target.read_dir_utf8()? {
89 let entry = entry?;
90 if entry.file_name() != ".git" {
91 return Ok(true);
92 }
93 }
94 Ok(false)
95}
96
97pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
103 std::fs::read_to_string(target.join(MANIFEST_PATH))
104 .ok()
105 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
106 .and_then(|value| value.get(key).cloned())
107 .filter(|value| !value.is_null())
108}
109
110fn installed_at(target: &Utf8Path) -> String {
111 recorded_field(target, "installed_at")
112 .and_then(|value| value.as_str().map(String::from))
113 .unwrap_or_else(|| {
114 jiff::Timestamp::now()
115 .strftime("%Y-%m-%dT%H:%M:%SZ")
116 .to_string()
117 })
118}
119
120pub(crate) fn resolved_plan_zone(
136 target: &Utf8Path,
137 flag: Option<&PlanZone>,
138) -> Result<PlanZone, AppError> {
139 if let Some(zone) = flag {
140 return Ok(zone.clone());
141 }
142 let Some(recorded) = recorded_field(target, "plan_zone") else {
143 return Ok(PlanZone::default());
144 };
145 let zone: PlanZone = serde_json::from_value(recorded).map_err(|source| {
146 AppError::ManifestInvalid(format!(
147 "the recorded plan_zone is in a shape this sdd does not read ({source}); \
148 upgrade sdd, or re-declare it with --plan-zone"
149 ))
150 })?;
151 if let Some(path) = zone.path()
155 && let Err(error) = validate_plan_zone_path(path)
156 {
157 return Err(AppError::ManifestInvalid(format!(
158 "the recorded plan_zone is not usable ({error}); re-declare it with --plan-zone"
159 )));
160 }
161 Ok(zone)
162}
163
164pub(crate) fn resolved_docs_scratch(
173 target: &Utf8Path,
174 flag: Option<&Option<Utf8PathBuf>>,
175) -> Result<Option<Utf8PathBuf>, AppError> {
176 if let Some(declared) = flag {
177 return Ok(declared.clone());
178 }
179 let Some(recorded) = recorded_field(target, "docs_scratch") else {
180 return Ok(None);
181 };
182 let path = recorded
183 .as_str()
184 .filter(|path| !path.is_empty())
185 .map(Utf8PathBuf::from)
186 .ok_or_else(|| {
187 AppError::ManifestInvalid(format!(
188 "the recorded docs_scratch is not a path ({recorded}); \
189 re-declare it with --docs-scratch"
190 ))
191 })?;
192 if let Err(error) = validate_docs_scratch_path(&path) {
193 return Err(AppError::ManifestInvalid(format!(
194 "the recorded docs_scratch is not usable ({error}); \
195 re-declare it with --docs-scratch"
196 )));
197 }
198 Ok(Some(path))
199}
200
201#[derive(Debug, Clone)]
207pub struct TargetState {
208 pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
210 pub lines: Vec<String>,
212}
213
214#[allow(
215 clippy::too_many_lines,
216 reason = "computing the target state is one ordered pass the installer replays"
217)]
218pub fn compute_target_state(
225 target: &Utf8Path,
226 options: &InitOptions,
227 bundle: &dyn ReleaseBundle,
228) -> Result<TargetState, AppError> {
229 let profile = options.profile;
230 let landed: CanonVersion = bundle
235 .manifest()?
236 .version
237 .to_string()
238 .parse()
239 .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
240 let released = bundle.declaration()?;
241 let declaration = released.profile(profile).ok_or_else(|| {
242 AppError::Refused(format!(
243 "the release declares no {profile} profile, so it cannot land one"
244 ))
245 })?;
246 let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
247 let mut lines = Vec::new();
248 let mut managed_entries = Vec::new();
249 let mut adopted_entries = Vec::new();
250
251 for projection in declaration.managed {
252 let bytes = bundle.artifact(&projection.source)?;
253 let destination = Utf8PathBuf::from(&projection.destination);
254 managed_entries.push(ManagedEntry {
255 source: projection.source.clone().into(),
256 destination: destination.clone(),
257 sha256: Sha256::of(&bytes),
258 });
259 lines.push(destination.to_string());
260 files.push((destination, bytes));
261 }
262
263 let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
268 .and_then(|value| {
269 value.as_array().map(|entries| {
270 entries
271 .iter()
272 .filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
273 .collect()
274 })
275 })
276 .unwrap_or_default();
277 for projection in declaration.adopted {
278 let seed = bundle.artifact(&projection.source)?;
279 let destination = resolve_destination(&projection.destination, declaration.docs_root);
280 let existing = target.join(&destination);
281 let mut bytes = if existing.is_file() {
282 let held = std::fs::read(&existing)?;
283 if held != seed && !recorded_adopted.iter().any(|d| d == destination.as_str()) {
284 lines.push(format!(
285 "note: {destination} already exists and is kept; the seed was not written, so read it with 'sdd spec' and reconcile by hand"
286 ));
287 }
288 held
289 } else {
290 seed.clone()
291 };
292 if destination == crate::domain::instance_config::CONFIG_PATH
295 && let Ok(text) = std::str::from_utf8(&bytes)
296 {
297 let mut text = text.to_string();
298 if !options.reserve.is_empty() {
299 text = crate::domain::instance_config::with_reserved(&text, &options.reserve);
300 }
301 if let Some(selection) = &options.writing_style {
302 text = crate::domain::instance_config::with_writing_style(&text, selection);
303 }
304 bytes = text.into_bytes();
305 }
306 adopted_entries.push(AdoptedEntry {
307 source: projection.source.clone().into(),
308 destination: destination.clone(),
309 sha256: Sha256::of(&bytes),
310 baseline_sha256: Sha256::of(&seed),
311 });
312 lines.push(destination.to_string());
313 files.push((destination, bytes));
314 }
315
316 let config_path = target.join(HOOKS_CONFIG_PATH);
317 let host = if config_path.is_file() {
318 std::fs::read_to_string(&config_path)?
319 } else {
320 "repos:\n".to_string()
321 };
322 let (base, _) = crate::domain::marker::split_block(&host)?;
323 let indent = crate::domain::marker::splice_indent(&base)?;
324 let declared = files
328 .iter()
329 .find(|(destination, _)| destination == crate::domain::instance_config::CONFIG_PATH)
330 .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
331 .map(crate::domain::instance_config::InstanceConfig::parse)
332 .transpose()
333 .map_err(|error| anyhow::anyhow!("{error}"))?
334 .unwrap_or_default();
335 let writing_style = declared.writing_style.clone();
336 let block = render_block(&RenderOptions {
337 docs_root: declaration.docs_root.to_string(),
338 indent,
339 declaration: declared,
340 ..RenderOptions::default()
341 });
342 let spliced = crate::domain::marker::splice(&base, &block)?;
343 let marker_hash = crate::domain::marker::block_hash(&spliced)
344 .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
345 lines.push(HOOKS_CONFIG_PATH.to_string());
346 files.push((Utf8PathBuf::from(HOOKS_CONFIG_PATH), spliced.into_bytes()));
347
348 let mut integration_blocks = vec![IntegrationBlock {
349 path: HOOKS_CONFIG_PATH.into(),
350 marker_hash,
351 }];
352
353 let agents_relative = Utf8Path::new(AGENTS_DIGEST_PATH);
357 if target.join(agents_relative).is_symlink() {
358 return Err(AppError::Refused(
359 "AGENTS.md is a symlink; refusing to write the documentation block through it"
360 .to_string(),
361 ));
362 }
363 let agents_host = if target.join(agents_relative).is_file() {
364 std::fs::read_to_string(target.join(agents_relative))?
365 } else {
366 String::new()
367 };
368 let agents_block = crate::services::agents_render::render_block(
369 &declaration.docs_root.to_string(),
370 &writing_style,
371 );
372 let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
373 let agents_hash = crate::domain::marker::block_hash_with(
374 &agents,
375 crate::domain::marker::AGENTS_BEGIN,
376 crate::domain::marker::AGENTS_END,
377 )
378 .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
379 if agents_host.contains("## Documentation")
382 && crate::domain::marker::block_region_with(
383 &agents_host,
384 crate::domain::marker::AGENTS_BEGIN,
385 crate::domain::marker::AGENTS_END,
386 )
387 .is_none()
388 {
389 lines.push(
390 "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(),
391 );
392 }
393 lines.push(AGENTS_DIGEST_PATH.to_string());
394 files.push((agents_relative.to_path_buf(), agents.into_bytes()));
395 integration_blocks.push(IntegrationBlock {
396 path: AGENTS_DIGEST_PATH.into(),
397 marker_hash: agents_hash,
398 });
399
400 let manifest = Manifest {
401 schema_version: SCHEMA_VERSION,
402 canon_version: landed,
403 canon_source: CANON_SOURCE.to_string(),
404 profile,
405 docs_root: declaration.docs_root,
406 installed_at: installed_at(target),
407 plan_zone: resolved_plan_zone(target, options.plan_zone.as_ref())?,
408 docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
409 managed_files: managed_entries,
410 adopted_files: adopted_entries,
411 integration_blocks,
412 };
413 lines.push(MANIFEST_PATH.to_string());
414 files.push((
415 Utf8PathBuf::from(MANIFEST_PATH),
416 manifest.to_json().into_bytes(),
417 ));
418
419 Ok(TargetState { files, lines })
420}
421
422pub fn init(
431 options: &InitOptions,
432 bundle: &dyn ReleaseBundle,
433 intent: crate::plan::classify::Intent,
434) -> Result<InitOutcome, AppError> {
435 init_with(
436 &crate::plan::decision::Selections::new(),
437 options,
438 bundle,
439 intent,
440 )
441}
442
443pub fn init_with(
450 answered: &crate::plan::decision::Selections,
451 options: &InitOptions,
452 bundle: &dyn ReleaseBundle,
453 intent: crate::plan::classify::Intent,
454) -> Result<InitOutcome, AppError> {
455 let target = canonical_target(&options.target)?;
456 crate::commands::front::serves(intent, &target)?;
460 let forced_dry = !options.apply
461 && !options.dry_run
462 && target_has_content(&target)?
463 && !target.join(MANIFEST_PATH).is_file();
464 let dry = options.dry_run || forced_dry;
465
466 let state = compute_target_state(&target, options, bundle)?;
467 let mut lines = state.lines;
468
469 let landing = crate::plan::session::Landing {
470 target: &target,
471 release: crate::plan::session::ReleaseRef::of(bundle)?,
472 offline: true,
473 selections: answered.clone(),
474 carried: profile_only(options.profile),
475 reserve: options.reserve.clone(),
476 declared: Some(options.clone()),
477 };
478
479 if dry {
480 if forced_dry {
481 lines.push(
482 "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
483 .to_string(),
484 );
485 }
486 lines.extend(crate::plan::session::preview_lines(
490 &crate::plan::session::preview(&landing)?,
491 ));
492 lines.push("DRY RUN: no files written".to_string());
493 return Ok(InitOutcome {
494 lines,
495 applied: false,
496 removed: Vec::new(),
497 });
498 }
499
500 let result = crate::plan::session::land(&landing)?;
506 for refused in result
507 .postconditions
508 .iter()
509 .filter(|postcondition| !postcondition.held)
510 {
511 lines.push(format!(
512 "FAIL {} did not hold: {}",
513 refused.id,
514 refused.detail.clone().unwrap_or_default()
515 ));
516 }
517 Ok(InitOutcome {
518 lines,
519 applied: true,
520 removed: result
521 .operations
522 .iter()
523 .filter(|operation| operation.kind == "remove-owned-file")
524 .map(|operation| operation.path.clone())
525 .collect(),
526 })
527}
528
529fn profile_only(profile: ProfileId) -> crate::plan::decision::Selections {
536 let mut selections = crate::plan::decision::Selections::new();
537 selections.insert(
538 crate::plan::decision::id::PROFILE.to_string(),
539 profile.to_string(),
540 );
541 selections
542}