Skip to main content

spec_driven_docs/
candidate.rs

1//! The candidate this binary renders for one target.
2//!
3//! One pure function owns every byte a landing would write. It reads the
4//! sources compiled into this binary and the evidence the caller gathered
5//! from the target, and it returns an ordered list of destinations plus the
6//! record that describes them. It reads no filesystem, no environment, no
7//! clock, and no network, so the same input renders the same bytes whether
8//! a stage asked or a production landing did.
9//!
10//! What the caller observes about the target is a value in [`Evidence`].
11//! Observation is the caller's job, because a projection that looked at the
12//! world itself could not be replayed and could not be staged.
13
14use 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/// Who owns a destination's bytes after the landing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Ownership {
30    /// This tool keeps owning the bytes and refreshes them.
31    Managed,
32    /// The project owns the bytes from the moment they land.
33    Adopted,
34    /// The project owns the file and this tool owns one region inside it.
35    Integration,
36}
37
38/// How much of the destination the candidate carries.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Placement {
41    /// Every byte of the file.
42    WholeFile,
43    /// One marked region, with every byte outside it preserved.
44    MarkedRegion,
45}
46
47/// One destination of the candidate, and the bytes that would go there.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Destination {
50    /// The path, relative to the target root.
51    pub path: Utf8PathBuf,
52    /// Every byte the file would hold after the landing.
53    pub bytes: Vec<u8>,
54    /// Who owns those bytes afterwards.
55    pub ownership: Ownership,
56    /// Whether the candidate carries the file or one region of it.
57    pub placement: Placement,
58    /// The payload path that produced the bytes, where one did.
59    pub source: Option<String>,
60}
61
62/// The complete candidate for one target.
63#[derive(Debug, Clone)]
64pub struct Candidate {
65    /// Every destination, in the order a landing writes them.
66    pub destinations: Vec<Destination>,
67    /// The record that describes the landing, written after all of them.
68    pub manifest: Manifest,
69    /// What the gates judge, as the candidate's own declaration states it.
70    ///
71    /// This is the resolved value the rendered bytes carry, not the flags
72    /// the caller passed: a reader of the candidate needs what it says,
73    /// and the two differ wherever the project already declared something.
74    pub declaration: InstanceConfig,
75    /// What the operator is told about what the projection chose.
76    pub notes: Vec<String>,
77}
78
79impl Candidate {
80    /// Every destination and its bytes, with the record written last.
81    #[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/// What the caller observed about the target before projecting.
97///
98/// Every field is a value the caller read once. The projection never looks
99/// at the target itself, so a stage and a production run that observed the
100/// same target project the same bytes.
101#[derive(Debug, Clone, Default)]
102pub struct Evidence {
103    /// The bytes each existing destination holds, by target-relative path.
104    pub existing: BTreeMap<Utf8PathBuf, Vec<u8>>,
105    /// The destinations the instance record already calls adopted.
106    pub recorded_adopted: Vec<String>,
107    /// The hook configuration the target holds, or the empty default.
108    pub hooks_host: String,
109    /// The root author-instructions file the target holds, or nothing.
110    pub agents_host: String,
111}
112
113/// What a landing was asked to render.
114#[derive(Debug, Clone)]
115pub struct Input {
116    /// The profile to project.
117    pub profile: ProfileId,
118    /// The version of the binary doing the rendering.
119    pub version: CanonVersion,
120    /// The install timestamp to record, carried forward where one exists.
121    pub installed_at: String,
122    /// The documentation scratch to record.
123    pub docs_scratch: Option<Utf8PathBuf>,
124    /// Paths to record under `reserved:` in the instance declaration.
125    pub reserve: Vec<String>,
126    /// The writing-style selection to record in the declaration.
127    pub writing_style: Option<WritingStyle>,
128    /// What the caller observed about the target.
129    pub evidence: Evidence,
130}
131
132/// Render the candidate this binary carries for one target.
133///
134/// # Errors
135///
136/// [`AppError::Refused`] where this release declares no such profile, where
137/// a marked region cannot be read, or where two sources project onto one
138/// destination.
139#[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        // `--reserve` and `--writing-style` record into the declaration,
193        // keeping its comments and whatever the project already wrote there.
194        // What is already there is read first: a transformation applied to
195        // bytes nobody validated would repair a malformed declaration into
196        // a valid one and overwrite the file the operator has to fix.
197        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    // Render from the declaration this landing is writing, not from the one
231    // on disk. With `--reserve` they differ, and a block rendered from the
232    // old one would disagree with the file the same landing writes.
233    // The declaration the block is rendered from is the one this landing
234    // writes, already validated above.
235    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    // An old unmarked documentation section is preserved, never deleted; the
280    // note tells the operator to remove the duplicate by hand.
281    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
328/// The documentation root one profile of this release lands into.
329///
330/// # Errors
331///
332/// [`AppError::Refused`] where this release declares no such profile.
333pub 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
343/// One declaration's text, refusing bytes no reader can take.
344///
345/// A declaration that cannot be read is a refusal, never a default and
346/// never something a flag repairs on the way past: the landing would
347/// otherwise keep the project's bytes and wire the block from something
348/// else, and the two would disagree from the first commit onward.
349fn 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
360/// The bytes one payload source carries, from this binary's own sources.
361///
362/// # Errors
363///
364/// [`AppError::Refused`] where this release does not carry the source.
365pub 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
375/// Refuse two sources aimed at one destination before any writer runs.
376fn 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        // And a flag does not repair it on the way past. A transformation
541        // over bytes nobody validated would rewrite the file the operator
542        // has to fix and call the result a landing.
543        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}