1use std::collections::BTreeMap;
15
16use camino::Utf8PathBuf;
17
18use crate::domain::instance_config::{InstanceConfig, WritingStyle};
19use crate::domain::manifest::{CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION};
20use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
21use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
22use crate::domain::profile::{DocsRoot, ProfileId, resolve_destination};
23use crate::domain::version::CanonVersion;
24use crate::error::AppError;
25use crate::services::hooks_render::{RenderOptions, render_block};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Ownership {
30 Managed,
32 Adopted,
34 Integration,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Placement {
41 WholeFile,
43 MarkedRegion,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Destination {
50 pub path: Utf8PathBuf,
52 pub bytes: Vec<u8>,
54 pub ownership: Ownership,
56 pub placement: Placement,
58 pub source: Option<String>,
60}
61
62#[derive(Debug, Clone)]
64pub struct Candidate {
65 pub destinations: Vec<Destination>,
67 pub manifest: Manifest,
69 pub declaration: InstanceConfig,
75 pub notes: Vec<String>,
77}
78
79impl Candidate {
80 #[must_use]
82 pub fn files(&self) -> Vec<(Utf8PathBuf, Vec<u8>)> {
83 let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = self
84 .destinations
85 .iter()
86 .map(|destination| (destination.path.clone(), destination.bytes.clone()))
87 .collect();
88 files.push((
89 Utf8PathBuf::from(MANIFEST_PATH),
90 self.manifest.to_json().into_bytes(),
91 ));
92 files
93 }
94}
95
96#[derive(Debug, Clone, Default)]
102pub struct Evidence {
103 pub existing: BTreeMap<Utf8PathBuf, Vec<u8>>,
105 pub recorded_adopted: Vec<String>,
107 pub hooks_host: String,
109 pub agents_host: String,
111}
112
113#[derive(Debug, Clone)]
115pub struct Input {
116 pub profile: ProfileId,
118 pub version: CanonVersion,
120 pub installed_at: String,
122 pub docs_scratch: Option<Utf8PathBuf>,
124 pub reserve: Vec<String>,
126 pub writing_style: Option<WritingStyle>,
128 pub evidence: Evidence,
130}
131
132#[allow(
140 clippy::too_many_lines,
141 reason = "the candidate is one ordered pass, and splitting it would hide the order it defines"
142)]
143pub fn project(input: &Input) -> Result<Candidate, AppError> {
144 let declared = crate::domain::profile::DECLARATION
145 .profile(input.profile)
146 .ok_or_else(|| {
147 AppError::Refused(format!(
148 "this release declares no {} profile, so it cannot land one",
149 input.profile
150 ))
151 })?;
152 let docs_root = declared.docs_root;
153 let mut destinations: Vec<Destination> = Vec::new();
154 let mut notes: Vec<String> = Vec::new();
155 let mut managed_entries = Vec::new();
156 let mut adopted_entries = Vec::new();
157
158 for projection in declared.managed {
159 let bytes = source_bytes(&projection.source)?;
160 let path = Utf8PathBuf::from(&projection.destination);
161 managed_entries.push(ManagedEntry {
162 source: projection.source.clone().into(),
163 destination: path.clone(),
164 sha256: Sha256::of(&bytes),
165 });
166 destinations.push(Destination {
167 path,
168 bytes,
169 ownership: Ownership::Managed,
170 placement: Placement::WholeFile,
171 source: Some(projection.source.clone()),
172 });
173 }
174
175 for projection in declared.adopted {
176 let seed = source_bytes(&projection.source)?;
177 let path = resolve_destination(&projection.destination, docs_root);
178 let held = input.evidence.existing.get(&path);
179 if let Some(held) = held
180 && held != &seed
181 && !input
182 .evidence
183 .recorded_adopted
184 .iter()
185 .any(|recorded| recorded == path.as_str())
186 {
187 notes.push(format!(
188 "note: {path} already exists and is kept; the seed was not written, so read it with 'sdd spec' and reconcile by hand"
189 ));
190 }
191 let mut bytes = held.cloned().unwrap_or_else(|| seed.clone());
192 if path == crate::domain::instance_config::CONFIG_PATH {
198 let text = readable(&path, &bytes)?;
199 let mut text = text.to_string();
200 if !input.reserve.is_empty() {
201 text = crate::domain::instance_config::with_reserved(&text, &input.reserve);
202 }
203 if let Some(selection) = &input.writing_style {
204 text = crate::domain::instance_config::with_writing_style(&text, selection);
205 }
206 bytes = text.into_bytes();
207 }
208 adopted_entries.push(AdoptedEntry {
209 source: projection.source.clone().into(),
210 destination: path.clone(),
211 sha256: Sha256::of(&bytes),
212 baseline_sha256: Sha256::of(&seed),
213 });
214 destinations.push(Destination {
215 path,
216 bytes,
217 ownership: Ownership::Adopted,
218 placement: Placement::WholeFile,
219 source: Some(projection.source.clone()),
220 });
221 }
222
223 let host = if input.evidence.hooks_host.is_empty() {
224 "repos:\n".to_string()
225 } else {
226 input.evidence.hooks_host.clone()
227 };
228 let (base, _) = crate::domain::marker::split_block(&host)?;
229 let indent = crate::domain::marker::splice_indent(&base)?;
230 let declared = destinations
236 .iter()
237 .find(|destination| destination.path == crate::domain::instance_config::CONFIG_PATH);
238 let declaration = match declared {
239 Some(destination) => {
240 let text = readable(&destination.path, &destination.bytes)?;
241 InstanceConfig::parse(text).map_err(|error| {
242 AppError::Refused(format!("{} does not parse: {error}", destination.path))
243 })?
244 }
245 None => InstanceConfig::default(),
246 };
247 let writing_style = declaration.writing_style.clone();
248 let block = render_block(&RenderOptions {
249 docs_root: docs_root.to_string(),
250 indent,
251 declaration: declaration.clone(),
252 ..RenderOptions::default()
253 });
254 let spliced = crate::domain::marker::splice(&base, &block)?;
255 let marker_hash = crate::domain::marker::block_hash(&spliced)
256 .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
257 destinations.push(Destination {
258 path: Utf8PathBuf::from(HOOKS_CONFIG_PATH),
259 bytes: spliced.into_bytes(),
260 ownership: Ownership::Integration,
261 placement: Placement::MarkedRegion,
262 source: None,
263 });
264 let mut integration_blocks = vec![IntegrationBlock {
265 path: HOOKS_CONFIG_PATH.into(),
266 marker_hash,
267 }];
268
269 let agents_block =
270 crate::services::agents_render::render_block(&docs_root.to_string(), &writing_style);
271 let agents =
272 crate::domain::marker::place_agents_block(&input.evidence.agents_host, &agents_block)?;
273 let agents_hash = crate::domain::marker::block_hash_with(
274 &agents,
275 crate::domain::marker::AGENTS_BEGIN,
276 crate::domain::marker::AGENTS_END,
277 )
278 .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
279 if input.evidence.agents_host.contains("## Documentation")
282 && crate::domain::marker::block_region_with(
283 &input.evidence.agents_host,
284 crate::domain::marker::AGENTS_BEGIN,
285 crate::domain::marker::AGENTS_END,
286 )
287 .is_none()
288 {
289 notes.push(
290 "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(),
291 );
292 }
293 destinations.push(Destination {
294 path: Utf8PathBuf::from(AGENTS_DIGEST_PATH),
295 bytes: agents.into_bytes(),
296 ownership: Ownership::Integration,
297 placement: Placement::MarkedRegion,
298 source: None,
299 });
300 integration_blocks.push(IntegrationBlock {
301 path: AGENTS_DIGEST_PATH.into(),
302 marker_hash: agents_hash,
303 });
304
305 one_destination_each(&destinations)?;
306
307 let manifest = Manifest {
308 schema_version: SCHEMA_VERSION,
309 canon_version: input.version,
310 canon_source: CANON_SOURCE.to_string(),
311 profile: input.profile,
312 docs_root,
313 installed_at: input.installed_at.clone(),
314 docs_scratch: input.docs_scratch.clone(),
315 managed_files: managed_entries,
316 adopted_files: adopted_entries,
317 integration_blocks,
318 };
319
320 Ok(Candidate {
321 destinations,
322 manifest,
323 declaration,
324 notes,
325 })
326}
327
328pub fn docs_root_of(profile: ProfileId) -> Result<DocsRoot, AppError> {
334 crate::domain::profile::DECLARATION
335 .docs_root(profile)
336 .ok_or_else(|| {
337 AppError::Refused(format!(
338 "this release declares no {profile} profile, so it cannot land one"
339 ))
340 })
341}
342
343fn readable<'a>(path: &Utf8PathBuf, bytes: &'a [u8]) -> Result<&'a str, AppError> {
350 let text = std::str::from_utf8(bytes).map_err(|source| {
351 AppError::Refused(format!(
352 "{path} is not UTF-8, so what the gates judge cannot be read: {source}"
353 ))
354 })?;
355 InstanceConfig::parse(text)
356 .map_err(|error| AppError::Refused(format!("{path} does not parse: {error}")))?;
357 Ok(text)
358}
359
360pub fn source_bytes(source: &str) -> Result<Vec<u8>, AppError> {
366 crate::embedded::asset(source)
367 .map(<[u8]>::to_vec)
368 .ok_or_else(|| {
369 AppError::Refused(format!(
370 "this release projects {source}, and its own payload does not carry it"
371 ))
372 })
373}
374
375fn one_destination_each(destinations: &[Destination]) -> Result<(), AppError> {
377 let mut seen: Vec<&Utf8PathBuf> = Vec::with_capacity(destinations.len());
378 for destination in destinations {
379 if seen.contains(&&destination.path) {
380 return Err(AppError::Refused(format!(
381 "{} is projected twice, so the candidate does not describe one file",
382 destination.path
383 )));
384 }
385 seen.push(&destination.path);
386 }
387 Ok(())
388}
389
390#[cfg(test)]
391mod tests {
392 #![allow(
393 clippy::unwrap_used,
394 reason = "a test panics as its failure signal, not as control flow"
395 )]
396
397 use super::*;
398
399 fn input(profile: ProfileId) -> Input {
400 Input {
401 profile,
402 version: CanonVersion::current(),
403 installed_at: "2026-01-01T00:00:00Z".to_string(),
404 docs_scratch: None,
405 reserve: Vec::new(),
406 writing_style: None,
407 evidence: Evidence::default(),
408 }
409 }
410
411 #[test]
412 fn equal_inputs_render_byte_identical_candidates() {
413 let first = project(&input(ProfileId::Codebase)).unwrap();
414 let second = project(&input(ProfileId::Codebase)).unwrap();
415 assert_eq!(first.destinations, second.destinations);
416 assert_eq!(first.manifest.to_json(), second.manifest.to_json());
417 }
418
419 #[test]
420 fn every_profile_lands_its_own_root() {
421 for profile in ProfileId::every() {
422 let candidate = project(&input(profile)).unwrap();
423 assert_eq!(candidate.manifest.profile, profile);
424 assert_eq!(candidate.manifest.docs_root, docs_root_of(profile).unwrap());
425 }
426 }
427
428 #[test]
429 fn the_record_is_the_last_file_a_landing_writes() {
430 let candidate = project(&input(ProfileId::KnowledgeBase)).unwrap();
431 let files = candidate.files();
432 assert_eq!(files.last().unwrap().0, Utf8PathBuf::from(MANIFEST_PATH));
433 assert_eq!(files.len(), candidate.destinations.len() + 1);
434 }
435
436 #[test]
437 fn one_documentation_root_serves_the_whole_candidate() {
438 let candidate = project(&input(ProfileId::KnowledgeBase)).unwrap();
439 let root = candidate.manifest.docs_root;
440 for entry in &candidate.manifest.adopted_files {
441 if entry.destination == crate::domain::paths::CONFIG_PATH {
442 continue;
443 }
444 assert!(
445 entry.destination.as_str().starts_with(&format!("{root}/")),
446 "{} is outside the recorded root {root}",
447 entry.destination
448 );
449 }
450 }
451
452 #[test]
453 fn an_adopted_destination_the_project_wrote_is_kept_and_noted() {
454 let mut held = input(ProfileId::KnowledgeBase);
455 let candidate = project(&held).unwrap();
456 let adopted = candidate
457 .destinations
458 .iter()
459 .find(|destination| {
460 destination.ownership == Ownership::Adopted
461 && destination.path != crate::domain::paths::CONFIG_PATH
462 })
463 .unwrap()
464 .clone();
465 held.evidence
466 .existing
467 .insert(adopted.path.clone(), b"the project wrote this".to_vec());
468
469 let second = project(&held).unwrap();
470 let kept = second
471 .destinations
472 .iter()
473 .find(|destination| destination.path == adopted.path)
474 .unwrap();
475 assert_eq!(kept.bytes, b"the project wrote this");
476 assert!(
477 second
478 .notes
479 .iter()
480 .any(|note| note.contains(adopted.path.as_str()))
481 );
482 }
483
484 #[test]
485 fn a_recorded_adopted_destination_is_kept_without_a_note() {
486 let mut held = input(ProfileId::KnowledgeBase);
487 let candidate = project(&held).unwrap();
488 let adopted = candidate
489 .destinations
490 .iter()
491 .find(|destination| {
492 destination.ownership == Ownership::Adopted
493 && destination.path != crate::domain::paths::CONFIG_PATH
494 })
495 .unwrap()
496 .clone();
497 held.evidence
498 .existing
499 .insert(adopted.path.clone(), b"the project wrote this".to_vec());
500 held.evidence
501 .recorded_adopted
502 .push(adopted.path.to_string());
503
504 let second = project(&held).unwrap();
505 assert!(second.notes.is_empty(), "{:?}", second.notes);
506 }
507
508 #[test]
509 fn a_marked_region_preserves_every_byte_outside_it() {
510 let mut held = input(ProfileId::Codebase);
511 held.evidence.agents_host = "# Project\n\nOur own paragraph.\n".to_string();
512 let candidate = project(&held).unwrap();
513 let agents = candidate
514 .destinations
515 .iter()
516 .find(|destination| destination.path == AGENTS_DIGEST_PATH)
517 .unwrap();
518 let text = String::from_utf8(agents.bytes.clone()).unwrap();
519 assert!(text.contains("Our own paragraph."));
520 assert_eq!(agents.placement, Placement::MarkedRegion);
521 }
522
523 #[test]
524 fn a_declaration_that_cannot_be_read_refuses_rather_than_defaulting() {
525 let mut held = input(ProfileId::KnowledgeBase);
526 held.evidence.existing.insert(
527 Utf8PathBuf::from(crate::domain::paths::CONFIG_PATH),
528 b"\xff\xfe not text".to_vec(),
529 );
530 let error = project(&held).unwrap_err();
531 assert!(error.to_string().contains("not UTF-8"), "{error}");
532
533 held.evidence.existing.insert(
534 Utf8PathBuf::from(crate::domain::paths::CONFIG_PATH),
535 b"reserved: [".to_vec(),
536 );
537 let error = project(&held).unwrap_err();
538 assert!(error.to_string().contains("does not parse"), "{error}");
539
540 held.reserve = vec!["vendor/**".to_string()];
544 let error = project(&held).unwrap_err();
545 assert!(error.to_string().contains("does not parse"), "{error}");
546 }
547
548 #[test]
549 fn every_projected_source_comes_from_the_embedded_inventory() {
550 let candidate = project(&input(ProfileId::Codebase)).unwrap();
551 for destination in &candidate.destinations {
552 let Some(source) = &destination.source else {
553 continue;
554 };
555 assert!(
556 crate::embedded::asset(source).is_some(),
557 "{source} is projected and not embedded"
558 );
559 }
560 }
561}