1use camino::{Utf8Path, Utf8PathBuf};
14use serde::Serialize;
15
16use crate::domain::manifest::MANIFEST_PATH;
17use crate::domain::profile::ProfileId;
18use crate::error::AppError;
19
20pub const STAGE_SCHEMA: &str = "sdd.stage/1";
22
23pub const STAGE_ROOT_VAR: &str = "SDD_STAGE_ROOT";
25
26pub const RECEIPT_FILE: &str = "stage.json";
28
29pub const ARTIFACTS_DIR: &str = "artifacts";
31
32pub const REFERENCE_DIR: &str = "reference";
34
35const REFERENCE_ROOTS: [&str; 8] = [
42 "method",
43 "templates",
44 "_docs/specs",
45 "instance",
46 "comparison-docs",
47 "reference/prior-art",
48 "reference/tracker-markup",
49 ".markdownlint",
50];
51
52#[derive(Debug, Clone)]
58pub struct Request {
59 pub target: Utf8PathBuf,
61 pub profile: ProfileId,
63 pub output: Option<Utf8PathBuf>,
65 pub docs_scratch: Option<Option<Utf8PathBuf>>,
67 pub reserve: Vec<String>,
69 pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum RootSource {
77 Flag,
79 Environment,
81 Default,
83}
84
85#[derive(Debug, Clone, Serialize)]
87pub struct Artifact {
88 pub path: String,
90 pub ownership: String,
92 pub placement: String,
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct Receipt {
99 pub schema: &'static str,
101 pub version: String,
103 pub target: Utf8PathBuf,
105 pub profile: ProfileId,
107 pub root: Utf8PathBuf,
109 pub root_source: RootSource,
111 pub recorded_version: Option<String>,
113 pub docs_root: String,
115 pub docs_scratch: Option<Utf8PathBuf>,
117 pub reserve: Vec<String>,
119 pub writing_style: String,
121 pub artifacts: Vec<Artifact>,
123 pub reference: Vec<String>,
125 pub notes: Vec<String>,
127}
128
129pub fn resolve_root(
139 target: &Utf8Path,
140 output: Option<&Utf8Path>,
141 env: Option<&str>,
142 state_root: &Utf8Path,
143) -> Result<(Utf8PathBuf, RootSource), AppError> {
144 let (root, source) = match (output, env) {
145 (Some(named), _) => (named.to_owned(), RootSource::Flag),
146 (None, Some(base)) if !base.is_empty() => (
147 Utf8Path::new(base).join(stage_name(target)),
148 RootSource::Environment,
149 ),
150 _ => (
151 state_root.join("stages").join(stage_name(target)),
152 RootSource::Default,
153 ),
154 };
155 if !root.is_absolute() {
156 return Err(AppError::Usage(format!(
157 "the stage root must be absolute: {root}"
158 )));
159 }
160 if root
164 .components()
165 .any(|part| part.as_str() == ".." || part.as_str() == ".")
166 {
167 return Err(AppError::Usage(format!(
168 "the stage root names a relative step: {root}; give the path it resolves to"
169 )));
170 }
171 Ok((root, source))
172}
173
174fn resolved_ancestor(path: &Utf8Path) -> Utf8PathBuf {
179 let mut walked = path.to_owned();
180 loop {
181 if let Ok(canonical) = std::fs::canonicalize(walked.as_std_path())
182 && let Ok(canonical) = Utf8PathBuf::from_path_buf(canonical)
183 {
184 let rest = path
185 .strip_prefix(&walked)
186 .unwrap_or_else(|_| Utf8Path::new(""));
187 return canonical.join(rest);
188 }
189 let Some(parent) = walked.parent() else {
190 return path.to_owned();
191 };
192 walked = parent.to_owned();
193 }
194}
195
196fn writing_style_of(declaration: &crate::domain::instance_config::InstanceConfig) -> String {
198 use crate::domain::instance_config::WritingSource;
199
200 match declaration.writing_style.source {
201 WritingSource::Builtin => "builtin".to_string(),
202 WritingSource::None => "none".to_string(),
203 WritingSource::Project => declaration
204 .writing_style
205 .path
206 .as_ref()
207 .map_or_else(|| "project".to_string(), |path| format!("project:{path}")),
208 }
209}
210
211fn scratch_beside(root: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
217 let stamp = jiff::Timestamp::now().as_nanosecond();
218 let partial = Utf8PathBuf::from(format!("{root}.partial-{}-{stamp}", std::process::id()));
219 if let Some(parent) = partial.parent() {
220 std::fs::create_dir_all(parent)?;
221 }
222 std::fs::create_dir(&partial).map_err(|source| {
223 AppError::Refused(format!(
224 "{partial} could not be created for this run: {source}"
225 ))
226 })?;
227 Ok(partial)
228}
229
230fn stage_name(target: &Utf8Path) -> String {
235 let digest = crate::domain::ownership::Sha256::of(target.as_str().as_bytes()).to_string();
236 let leaf = target.file_name().unwrap_or("target");
237 format!("{leaf}-{}", &digest[..12])
238}
239
240pub fn create(request: &Request, state_root: &Utf8Path) -> Result<Receipt, AppError> {
248 let target = crate::services::installer::resolved_target(&request.target)?;
249 let env = std::env::var(STAGE_ROOT_VAR).ok();
250 let (root, root_source) = resolve_root(
251 &target,
252 request.output.as_deref(),
253 env.as_deref(),
254 state_root,
255 )?;
256
257 let resolved = resolved_ancestor(&root);
263 if resolved == target || resolved.starts_with(&target) {
264 return Err(AppError::Refused(format!(
265 "{root} is inside {target}, and a stage is written outside the target it describes"
266 )));
267 }
268 if root.exists() && root.read_dir_utf8().is_ok_and(|mut it| it.next().is_some()) {
269 return Err(AppError::Refused(format!(
270 "{root} already holds a stage; read it, or remove it with 'sdd stage clean {root}'"
271 )));
272 }
273
274 let options = crate::services::installer::InitOptions {
275 target: target.clone(),
276 profile: request.profile,
277 apply: false,
278 dry_run: true,
279 docs_scratch: request.docs_scratch.clone(),
280 reserve: request.reserve.clone(),
281 writing_style: request.writing_style.clone(),
282 };
283 let candidate = crate::services::installer::candidate_for(&target, &options)?;
284
285 let partial = scratch_beside(&root)?;
290 let render = render(&partial, &target, &root, root_source, &candidate, request);
291 match render {
292 Ok(receipt) => finish(&partial, &root, receipt),
293 Err(error) => {
294 let _ = std::fs::remove_dir_all(&partial);
298 Err(error)
299 }
300 }
301}
302
303fn finish(partial: &Utf8Path, root: &Utf8Path, receipt: Receipt) -> Result<Receipt, AppError> {
305 if let Some(parent) = root.parent() {
306 std::fs::create_dir_all(parent)?;
307 }
308 let _ = std::fs::remove_dir(root);
311 if let Err(source) = std::fs::rename(partial, root) {
312 let _ = std::fs::remove_dir_all(partial);
313 return Err(AppError::Io(source));
314 }
315 Ok(receipt)
316}
317
318fn render(
320 partial: &Utf8Path,
321 target: &Utf8Path,
322 root: &Utf8Path,
323 root_source: RootSource,
324 candidate: &crate::candidate::Candidate,
325 request: &Request,
326) -> Result<Receipt, AppError> {
327 let mut artifacts = Vec::new();
328 for destination in &candidate.destinations {
329 let path = partial.join(ARTIFACTS_DIR).join(&destination.path);
330 crate::adapters::fs::write_file(&path, &destination.bytes)?;
331 artifacts.push(Artifact {
332 path: destination.path.to_string(),
333 ownership: match destination.ownership {
334 crate::candidate::Ownership::Managed => "managed",
335 crate::candidate::Ownership::Adopted => "adopted",
336 crate::candidate::Ownership::Integration => "integration",
337 }
338 .to_string(),
339 placement: match destination.placement {
340 crate::candidate::Placement::WholeFile => "whole-file",
341 crate::candidate::Placement::MarkedRegion => "marked-region",
342 }
343 .to_string(),
344 });
345 }
346 crate::adapters::fs::write_file(
347 &partial.join(ARTIFACTS_DIR).join(MANIFEST_PATH),
348 candidate.manifest.to_json().as_bytes(),
349 )?;
350 artifacts.push(Artifact {
351 path: MANIFEST_PATH.to_string(),
352 ownership: "record".to_string(),
353 placement: "whole-file".to_string(),
354 });
355
356 let mut reference = Vec::new();
357
358 for name in crate::embedded::skill_names() {
362 let Some(package) = crate::embedded::skill_package(name) else {
363 continue;
364 };
365 for (relative, bytes) in package {
366 crate::adapters::fs::write_file(
367 &partial
368 .join(REFERENCE_DIR)
369 .join("skills")
370 .join(name)
371 .join(&relative),
372 bytes,
373 )?;
374 }
375 }
376 reference.push(format!("{REFERENCE_DIR}/skills"));
377
378 crate::adapters::fs::write_file(
381 &partial.join(REFERENCE_DIR).join("CHANGELOG.md"),
382 crate::embedded::CHANGELOG.as_bytes(),
383 )?;
384 reference.push(format!("{REFERENCE_DIR}/CHANGELOG.md"));
385
386 for root_name in REFERENCE_ROOTS {
387 let mut carried = false;
388 for (path, bytes) in crate::embedded::assets_under(root_name) {
389 crate::adapters::fs::write_file(&partial.join(REFERENCE_DIR).join(&path), bytes)?;
390 carried = true;
391 }
392 if carried {
393 reference.push(format!("{REFERENCE_DIR}/{root_name}"));
394 }
395 }
396 if let Some(bytes) = crate::embedded::asset("instance/docs-catalog.toml") {
397 crate::adapters::fs::write_file(
398 &partial
399 .join(REFERENCE_DIR)
400 .join("instance/docs-catalog.toml"),
401 bytes,
402 )?;
403 reference.push(format!("{REFERENCE_DIR}/instance/docs-catalog.toml"));
404 }
405
406 let receipt = Receipt {
407 schema: STAGE_SCHEMA,
408 version: crate::domain::version::CanonVersion::current().to_string(),
409 target: target.to_owned(),
410 profile: request.profile,
411 root: root.to_owned(),
412 root_source,
413 recorded_version: crate::services::installer::recorded_field(target, "canon_version")
414 .and_then(|value| value.as_str().map(String::from)),
415 docs_root: candidate.manifest.docs_root.to_string(),
416 docs_scratch: candidate.manifest.docs_scratch.clone(),
417 reserve: candidate.declaration.reserved.clone(),
418 writing_style: writing_style_of(&candidate.declaration),
419 artifacts,
420 reference,
421 notes: candidate.notes.clone(),
422 };
423 let json = serde_json::to_string_pretty(&receipt)
424 .map_err(|error| anyhow::anyhow!("the stage receipt does not serialize: {error}"))?;
425 crate::adapters::fs::write_file(&partial.join(RECEIPT_FILE), json.as_bytes())?;
426
427 Ok(receipt)
428}
429
430pub fn clean(path: &Utf8Path) -> Result<Vec<String>, AppError> {
438 if !path.is_absolute() {
439 return Err(AppError::Usage(format!(
440 "the stage path must be absolute: {path}"
441 )));
442 }
443 let held = std::fs::symlink_metadata(path)
446 .map_err(|source| AppError::Refused(format!("{path}: {source}")))?;
447 if held.file_type().is_symlink() {
448 return Err(AppError::Refused(format!(
449 "{path} is a symlink; a stage is a directory this tool wrote"
450 )));
451 }
452 if !held.is_dir() {
453 return Err(AppError::Refused(format!("{path} is not a directory")));
454 }
455 if path.parent().is_none() || path.as_str().matches('/').count() < 2 {
456 return Err(AppError::Refused(format!(
457 "{path} is too close to the filesystem root to remove"
458 )));
459 }
460 if path.join(MANIFEST_PATH).exists() || path.join(".git").exists() {
461 return Err(AppError::Refused(format!(
462 "{path} looks like a project rather than a stage"
463 )));
464 }
465 if let Some(home) = crate::domain::paths::UserEnv::from_process().home
469 && path == home
470 {
471 return Err(AppError::Refused(format!(
472 "{path} is the home directory, which is never a stage"
473 )));
474 }
475 if let Ok(cwd) = std::env::current_dir()
476 && let Ok(cwd) = Utf8PathBuf::from_path_buf(cwd)
477 && cwd.starts_with(path)
478 {
479 return Err(AppError::Refused(format!(
480 "{path} holds the working directory, so it is not a stage this tool wrote"
481 )));
482 }
483 if !path.join(ARTIFACTS_DIR).is_dir() {
487 return Err(AppError::Refused(format!(
488 "{path} carries no {ARTIFACTS_DIR}/ directory, so it is not a stage this tool wrote"
489 )));
490 }
491
492 let receipt_path = path.join(RECEIPT_FILE);
493 let text = std::fs::read_to_string(&receipt_path).map_err(|source| {
494 AppError::Refused(format!(
495 "{receipt_path} is not readable, so this is not a stage this tool wrote: {source}"
496 ))
497 })?;
498 let receipt: serde_json::Value = serde_json::from_str(&text)
499 .map_err(|source| AppError::Refused(format!("{receipt_path} does not parse: {source}")))?;
500 if receipt.get("schema").and_then(serde_json::Value::as_str) != Some(STAGE_SCHEMA) {
501 return Err(AppError::Refused(format!(
502 "{receipt_path} does not declare {STAGE_SCHEMA}, so this is not a stage"
503 )));
504 }
505 let declared = receipt
506 .get("root")
507 .and_then(serde_json::Value::as_str)
508 .ok_or_else(|| AppError::Refused(format!("{receipt_path} declares no root")))?;
509 if declared != path.as_str() {
510 return Err(AppError::Refused(format!(
511 "{receipt_path} declares the root {declared}, and this path is {path}"
512 )));
513 }
514
515 let aside = scratch_beside(path)?;
519 let _ = std::fs::remove_dir(&aside);
520 std::fs::rename(path, &aside)?;
521 if let Err(source) = std::fs::remove_dir_all(&aside) {
522 if std::fs::rename(&aside, path).is_err() {
526 return Err(AppError::Refused(format!(
527 "{path} could not be removed and is now at {aside}: {source}"
528 )));
529 }
530 return Err(AppError::Io(source));
531 }
532 Ok(vec![format!("removed {path}")])
533}
534
535#[cfg(test)]
536mod tests {
537 #![allow(
538 clippy::unwrap_used,
539 reason = "a test panics as its failure signal, not as control flow"
540 )]
541
542 use super::*;
543
544 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
545 Utf8PathBuf::from(dir.path().to_str().unwrap())
546 }
547
548 #[test]
549 fn the_flag_wins_then_the_base_then_the_state_root() {
550 let target = Utf8Path::new("/work/project");
551 let state = Utf8Path::new("/state/spec-driven-docs");
552
553 let (path, source) = resolve_root(
554 target,
555 Some(Utf8Path::new("/tmp/here")),
556 Some("/base"),
557 state,
558 )
559 .unwrap();
560 assert_eq!(path, "/tmp/here");
561 assert_eq!(source, RootSource::Flag);
562
563 let (path, source) = resolve_root(target, None, Some("/base"), state).unwrap();
564 assert!(path.as_str().starts_with("/base/project-"), "{path}");
565 assert_eq!(source, RootSource::Environment);
566
567 let (path, source) = resolve_root(target, None, None, state).unwrap();
568 assert!(
569 path.as_str().starts_with(state.join("stages").as_str()),
570 "{path}"
571 );
572 assert_eq!(source, RootSource::Default);
573 }
574
575 #[test]
576 fn a_relative_stage_root_is_a_usage_error() {
577 let error = resolve_root(
578 Utf8Path::new("/work/project"),
579 Some(Utf8Path::new("stage")),
580 None,
581 Utf8Path::new("/state"),
582 )
583 .unwrap_err();
584 assert_eq!(error.kind(), "Usage");
585 }
586
587 #[test]
588 fn a_relative_step_in_the_stage_root_is_a_usage_error() {
589 for (output, env) in [
592 (Some(Utf8Path::new("/work/project/absent/../inside")), None),
593 (None, Some("/tmp/absent/../../work")),
594 ] {
595 let error = resolve_root(
596 Utf8Path::new("/work/project"),
597 output,
598 env,
599 Utf8Path::new("/state"),
600 )
601 .unwrap_err();
602 assert_eq!(error.kind(), "Usage");
603 assert!(error.to_string().contains("relative step"), "{error}");
604 }
605 }
606
607 #[test]
608 fn two_targets_stage_under_different_names() {
609 assert_ne!(
610 stage_name(Utf8Path::new("/one/project")),
611 stage_name(Utf8Path::new("/two/project"))
612 );
613 }
614
615 #[test]
616 fn clean_refuses_a_directory_that_is_not_a_stage() {
617 let dir = tempfile::tempdir().unwrap();
618 let path = root(&dir).join("not-a-stage");
619 std::fs::create_dir_all(&path).unwrap();
620 let error = clean(&path).unwrap_err();
621 assert_eq!(error.kind(), "Refused");
622 assert!(path.exists(), "the directory was removed anyway");
623 }
624
625 #[test]
626 fn clean_refuses_a_receipt_that_names_another_root() {
627 let dir = tempfile::tempdir().unwrap();
628 let path = root(&dir).join("stage");
629 std::fs::create_dir_all(path.join(ARTIFACTS_DIR)).unwrap();
630 crate::adapters::fs::write_file(
631 &path.join(RECEIPT_FILE),
632 format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"/elsewhere"}}"#).as_bytes(),
633 )
634 .unwrap();
635 let error = clean(&path).unwrap_err();
636 assert!(error.to_string().contains("/elsewhere"), "{error}");
637 assert!(path.exists());
638 }
639
640 #[test]
641 fn clean_refuses_a_link_standing_where_a_stage_stood() {
642 let dir = tempfile::tempdir().unwrap();
643 let real = root(&dir).join("stage");
644 std::fs::create_dir_all(real.join(ARTIFACTS_DIR)).unwrap();
645 crate::adapters::fs::write_file(
646 &real.join(RECEIPT_FILE),
647 format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{real}"}}"#).as_bytes(),
648 )
649 .unwrap();
650 let link = root(&dir).join("link");
651 std::os::unix::fs::symlink(real.as_std_path(), link.as_std_path()).unwrap();
652
653 let error = clean(&link).unwrap_err();
654 assert!(error.to_string().contains("symlink"), "{error}");
655 assert!(real.exists(), "the link's target was removed");
656 }
657
658 #[test]
659 fn clean_refuses_a_project_root() {
660 let dir = tempfile::tempdir().unwrap();
661 let path = root(&dir).join("project");
662 crate::adapters::fs::write_file(&path.join(MANIFEST_PATH), b"{}").unwrap();
663 crate::adapters::fs::write_file(
664 &path.join(RECEIPT_FILE),
665 format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
666 )
667 .unwrap();
668 let error = clean(&path).unwrap_err();
669 assert!(error.to_string().contains("project"), "{error}");
670 assert!(path.exists());
671 }
672
673 #[test]
674 fn clean_refuses_a_forged_receipt_over_a_directory_that_is_not_a_stage() {
675 let dir = tempfile::tempdir().unwrap();
676 let path = root(&dir).join("someones-files");
679 crate::adapters::fs::write_file(&path.join("notes.md"), b"mine").unwrap();
680 crate::adapters::fs::write_file(
681 &path.join(RECEIPT_FILE),
682 format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
683 )
684 .unwrap();
685
686 let error = clean(&path).unwrap_err();
687 assert!(error.to_string().contains(ARTIFACTS_DIR), "{error}");
688 assert!(path.join("notes.md").exists(), "the directory was removed");
689 }
690
691 #[test]
692 fn clean_refuses_a_directory_holding_the_working_directory() {
693 let dir = tempfile::tempdir().unwrap();
694 let path = Utf8PathBuf::from_path_buf(std::env::current_dir().unwrap())
695 .unwrap()
696 .parent()
697 .unwrap()
698 .to_owned();
699 let _ = dir;
700 let error = clean(&path).unwrap_err();
701 assert_eq!(error.kind(), "Refused");
702 assert!(path.exists());
703 }
704
705 #[test]
706 fn clean_removes_one_valid_stage() {
707 let dir = tempfile::tempdir().unwrap();
708 let path = root(&dir).join("stage");
709 crate::adapters::fs::write_file(&path.join(ARTIFACTS_DIR).join("AGENTS.md"), b"x").unwrap();
710 crate::adapters::fs::write_file(
711 &path.join(RECEIPT_FILE),
712 format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
713 )
714 .unwrap();
715 assert_eq!(clean(&path).unwrap().len(), 1);
716 assert!(!path.exists());
717 }
718}