Skip to main content

rac_engine/
scaffold.rs

1//! Scaffold writes — `decided new`, `decided init`, `decided quickstart`,
2//! `decided migrate metadata` (PORT-CONTRACT.d/16).
3//!
4//! Ports of `src/rac/core/idgen.py` (`generate_id`), `src/rac/core/
5//! templates.py` (`load_template`), `src/asdecided/services/init.py`
6//! (`init_repository`, `load_repository_config`, `write_mcp_configs` via
7//! `src/asdecided/services/profiles.py`), `src/asdecided/services/create.py`
8//! (`create_artifact`), `src/asdecided/services/quickstart.py` (`quickstart`),
9//! and `src/asdecided/services/migrate.py` (`migrate_metadata`).
10//!
11//! The packaged template bodies are embedded verbatim from
12//! `rust/decided-engine/assets/templates/`, vendored byte-identical copies of
13//! the Python package files — a unit test below pins that identity, because
14//! the written artifact must be byte-identical to what the oracle writes.
15//!
16//! Minted ids are wall-clock + CSPRNG derived (the oracle has no external
17//! seam); the parity harness masks them (`mask-ids`) on stdout AND captured
18//! file bytes, so this module uses the real clock and `/dev/urandom`.
19
20use std::collections::HashSet;
21use std::path::Path;
22
23use crate::pycompat::py_repr_str;
24use crate::relationships::corpus_items;
25use crate::spec::available_schemas;
26use crate::validate::find_config_file;
27use crate::walk::py_join;
28
29// ---------------------------------------------------------------------------
30// Errors (decided.services.{create,init,quickstart,migrate} exception classes)
31// ---------------------------------------------------------------------------
32
33/// The scaffold failure contract, message-shaped like the oracle's
34/// exception `str()`. Exit-code routing lives with each command handler,
35/// because the SAME error class maps to different exits per command
36/// (`OutputPathExists` is usage exit 2 under `new` but a refusal exit 1
37/// under `quickstart` — measured).
38pub enum ScaffoldError {
39    /// `TemplateNotFound` — unsupported artifact type (usage).
40    TemplateNotFound(String),
41    /// `OutputPathExists` — never overwrite (new: exit 2; quickstart: 1).
42    OutputPathExists(String),
43    /// `OutputDirectoryMissing` — no auto-create (usage).
44    OutputDirectoryMissing(String),
45    /// `MissingRepositoryConfig` — run `decided init` first (usage).
46    MissingRepositoryConfig(String),
47    /// `InvalidRepositoryKey` — bad key syntax (usage).
48    InvalidRepositoryKey(String),
49    /// `RepositoryKeyConflict` — established key differs (exit 1).
50    RepositoryKeyConflict(String),
51    /// `MalformedRepositoryConfig` — unreadable config (exit 1).
52    MalformedRepositoryConfig(String),
53    /// `IdGenerationExhausted` — broken entropy source (exit 1).
54    IdGenerationExhausted(String),
55    /// `CorpusNotEmpty` — quickstart refuses a non-empty corpus (exit 1).
56    CorpusNotEmpty(String),
57    /// `InvalidOrgEndpoint` — non-http(s) org endpoint URL (usage).
58    InvalidOrgEndpoint(String),
59    /// `MalformedClientConfig` — unmergeable MCP client config (exit 1).
60    MalformedClientConfig(String),
61}
62
63impl ScaffoldError {
64    pub fn message(&self) -> &str {
65        match self {
66            ScaffoldError::TemplateNotFound(m)
67            | ScaffoldError::OutputPathExists(m)
68            | ScaffoldError::OutputDirectoryMissing(m)
69            | ScaffoldError::MissingRepositoryConfig(m)
70            | ScaffoldError::InvalidRepositoryKey(m)
71            | ScaffoldError::RepositoryKeyConflict(m)
72            | ScaffoldError::MalformedRepositoryConfig(m)
73            | ScaffoldError::IdGenerationExhausted(m)
74            | ScaffoldError::CorpusNotEmpty(m)
75            | ScaffoldError::InvalidOrgEndpoint(m)
76            | ScaffoldError::MalformedClientConfig(m) => m,
77        }
78    }
79}
80
81fn template_not_found(artifact_type: &str) -> ScaffoldError {
82    ScaffoldError::TemplateNotFound(format!(
83        "unsupported artifact type: {artifact_type} (supported: {})",
84        available_schemas().join(", ")
85    ))
86}
87
88fn missing_repository_config(start_dir: &str) -> ScaffoldError {
89    ScaffoldError::MissingRepositoryConfig(format!(
90        "no repository identity found at or above {start_dir}; \
91         run `decided init` to establish a repository key first"
92    ))
93}
94
95fn malformed_config(config_path: &str, reason: &str) -> ScaffoldError {
96    ScaffoldError::MalformedRepositoryConfig(format!(
97        "malformed repository config {config_path}: {reason}"
98    ))
99}
100
101fn id_generation_exhausted() -> ScaffoldError {
102    ScaffoldError::IdGenerationExhausted(format!(
103        "could not generate a unique artifact ID in {MAX_ID_ATTEMPTS} attempts"
104    ))
105}
106
107// ---------------------------------------------------------------------------
108// Opaque id generation (decided.core.idgen, ADR-026)
109// ---------------------------------------------------------------------------
110
111/// Crockford base32: no I, L, O, U (visually ambiguous).
112pub const ID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
113
114const TIME_CHARS: usize = 8; // 40 bits of millisecond timestamp
115const RANDOM_CHARS: usize = 4; // 20 bits of CSPRNG entropy
116
117/// Bounded regeneration attempts on an index collision (create/migrate).
118const MAX_ID_ATTEMPTS: usize = 5;
119
120fn encode_base32(mut value: u64, chars: usize) -> String {
121    let mut out = vec![0u8; chars];
122    for slot in out.iter_mut().rev() {
123        *slot = ID_ALPHABET[(value & 0x1F) as usize];
124        value >>= 5;
125    }
126    String::from_utf8(out).expect("alphabet is ASCII")
127}
128
129/// 20 bits of CSPRNG entropy (`secrets.randbits(20)`), from /dev/urandom
130/// with a time/pid fallback so id minting never fails outright.
131fn random_bits_20() -> u64 {
132    use std::io::Read;
133    let mut buf = [0u8; 4];
134    if std::fs::File::open("/dev/urandom")
135        .and_then(|mut f| f.read_exact(&mut buf))
136        .is_ok()
137    {
138        return (u64::from(u32::from_le_bytes(buf))) & 0xF_FFFF;
139    }
140    let nanos = std::time::SystemTime::now()
141        .duration_since(std::time::UNIX_EPOCH)
142        .map(|d| d.subsec_nanos() as u64)
143        .unwrap_or(0);
144    (nanos ^ u64::from(std::process::id())) & 0xF_FFFF
145}
146
147/// `generate_id(repository_key)` — `<KEY>-` + 8-char millisecond-timestamp
148/// segment + 4-char random segment, Crockford base32.
149pub fn generate_id(repository_key: &str) -> String {
150    let millis = std::time::SystemTime::now()
151        .duration_since(std::time::UNIX_EPOCH)
152        .map(|d| d.as_millis() as u64)
153        .unwrap_or(0)
154        & ((1 << (TIME_CHARS * 5)) - 1);
155    format!(
156        "{repository_key}-{}{}",
157        encode_base32(millis, TIME_CHARS),
158        encode_base32(random_bits_20(), RANDOM_CHARS)
159    )
160}
161
162// ---------------------------------------------------------------------------
163// Canonical templates (decided.core.templates, ADR-021)
164// ---------------------------------------------------------------------------
165
166/// The embedded template bodies, index-aligned with `available_schemas()`
167/// registry order (requirement, decision, roadmap, prompt, design).
168const TEMPLATE_BYTES: [&str; 5] = [
169    include_str!("../assets/templates/requirement.md"),
170    include_str!("../assets/templates/decision.md"),
171    include_str!("../assets/templates/roadmap.md"),
172    include_str!("../assets/templates/prompt.md"),
173    include_str!("../assets/templates/design.md"),
174];
175
176/// `load_template(artifact_type)` — the canonical body, or
177/// `TemplateNotFound` for an unregistered type. `TemplateResourceMissing`
178/// (a broken Python installation) has no Rust equivalent: embedded
179/// resources cannot be absent from a linked binary.
180pub fn load_template(artifact_type: &str) -> Result<&'static str, ScaffoldError> {
181    available_schemas()
182        .iter()
183        .position(|name| *name == artifact_type)
184        .map(|i| TEMPLATE_BYTES[i])
185        .ok_or_else(|| template_not_found(artifact_type))
186}
187
188/// `render_frontmatter(artifact_id, artifact_type)` — canonical generated
189/// envelope, stable key order (v0.7.11 contract).
190pub fn render_frontmatter(artifact_id: &str, artifact_type: &str) -> String {
191    format!("---\nschema_version: 1\nid: {artifact_id}\ntype: {artifact_type}\n---\n")
192}
193
194// ---------------------------------------------------------------------------
195// Repository identity config (decided.services.init)
196// ---------------------------------------------------------------------------
197
198/// `KEY_RE = ^[A-Z][A-Z0-9]{1,9}$` — with Python `$` also matching just
199/// before one trailing newline (`re.match` semantics).
200fn valid_repository_key(key: &str) -> bool {
201    let core = key.strip_suffix('\n').unwrap_or(key);
202    let b = core.as_bytes();
203    (2..=10).contains(&b.len())
204        && b[0].is_ascii_uppercase()
205        && b.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
206}
207
208fn invalid_key_error(key: &str) -> ScaffoldError {
209    ScaffoldError::InvalidRepositoryKey(format!(
210        "invalid repository key: {} (expected 2-10 uppercase \
211         alphanumeric characters starting with a letter, e.g. RAC)",
212        py_repr_str(key)
213    ))
214}
215
216/// A discovered repository identity configuration.
217pub struct RepositoryConfig {
218    pub repository_key: String,
219    pub config_path: String,
220}
221
222/// `_read_config(config_path)` — strict read of one config file: YAML must
223/// parse (the invalid-YAML reason embeds this engine's own problem text —
224/// the oracle embeds PyYAML's; stderr-only divergence class), the root must
225/// be a mapping with a string `repository_key` matching the key contract.
226fn read_config(config_path: &str) -> Result<RepositoryConfig, ScaffoldError> {
227    let text = std::fs::read_to_string(config_path)
228        .map_err(|e| malformed_config(config_path, &format!("invalid YAML: {e}")))?;
229    let data = crate::frontmatter::yaml_load_config(&text)
230        .map_err(|problem| malformed_config(config_path, &format!("invalid YAML: {problem}")))?;
231    let key = match &data {
232        crate::frontmatter::Yaml::Map(pairs) => pairs.iter().find_map(|(k, v)| match (k, v) {
233            (crate::frontmatter::Yaml::Str(name), crate::frontmatter::Yaml::Str(value))
234                if name == "repository_key" =>
235            {
236                Some(value.clone())
237            }
238            _ => None,
239        }),
240        _ => None,
241    };
242    let Some(key) = key else {
243        return Err(malformed_config(
244            config_path,
245            "missing required string field 'repository_key'",
246        ));
247    };
248    if !valid_repository_key(&key) {
249        return Err(malformed_config(
250            config_path,
251            &format!("invalid repository_key: {}", py_repr_str(&key)),
252        ));
253    }
254    Ok(RepositoryConfig {
255        repository_key: key,
256        config_path: config_path.to_string(),
257    })
258}
259
260/// `load_repository_config(start_dir)` — the nearest `.decided/config.yaml` at
261/// or above the RESOLVED `start_dir`, read strictly, or None.
262pub fn load_repository_config(start_dir: &str) -> Result<Option<RepositoryConfig>, ScaffoldError> {
263    match find_config_file(start_dir) {
264        Some(path) => read_config(&path.to_string_lossy()).map(Some),
265        None => Ok(None),
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Init profiles (decided.services.profiles, ADR-088)
271// ---------------------------------------------------------------------------
272
273/// The AsDecided MCP server wiring, identical for Claude Code (`.mcp.json`) and
274/// Cursor (`.cursor/mcp.json`).
275pub const MCP_JSON: &str = "{\n  \"mcpServers\": {\n    \"asdecided\": {\n      \"command\": \"decided-mcp\",\n      \"args\": [\"--root\", \".\"]\n    }\n  }\n}\n";
276
277/// The enterprise profile's committed enforcement stanza (ADR-049/088) —
278/// appended verbatim after the repository key.
279const ENTERPRISE_CONFIG: &str = "\
280# Enterprise profile (ADR-088): relationship-integrity findings block `decided gate`,
281# committed explicitly so the enforcement policy is auditable (ADR-049).
282enforcement:
283  blocking:
284    - relationship-target-not-found
285    - relationship-target-ambiguous
286    - relationship-self-reference
287    - relationship-target-type-mismatch
288    - relationship-target-superseded
289    - relationship-cycle
290    - relationship-edge-unsupported
291    - duplicate-artifact-identifier
292";
293
294/// `(config_stanza, mcp_wiring)` for a built-in profile name. The CLI's
295/// argparse choices already reject unknown names (`InvalidProfile` is
296/// unreachable from the CLI, like the oracle).
297fn profile_parts(profile: &str) -> (&'static str, bool) {
298    match profile {
299        "enterprise" => (ENTERPRISE_CONFIG, true),
300        _ => ("", true), // "default"
301    }
302}
303
304/// `write_mcp_configs(directory)` — write the client wiring, never
305/// overwriting; returns the paths actually written, target order.
306fn write_mcp_configs(directory: &str) -> std::io::Result<Vec<String>> {
307    let targets: [&[&str]; 2] = [&[".mcp.json"], &[".cursor", "mcp.json"]];
308    let mut written = Vec::new();
309    for target in targets {
310        let path = py_join(directory, target);
311        if Path::new(&path).exists() {
312            continue;
313        }
314        if let Some(parent) = Path::new(&path).parent() {
315            std::fs::create_dir_all(parent)?;
316        }
317        std::fs::write(&path, MCP_JSON)?;
318        written.push(path);
319    }
320    Ok(written)
321}
322
323/// The shared org endpoint's server name in client configs (ADR-117).
324const ORG_SERVER_KEY: &str = "asdecided-org";
325
326fn invalid_org_endpoint(url: &str) -> ScaffoldError {
327    ScaffoldError::InvalidOrgEndpoint(format!(
328        "invalid org endpoint: {} (expected an http:// or https:// URL, \
329         e.g. https://asdecided.example.com/mcp)",
330        py_repr_str(url)
331    ))
332}
333
334fn malformed_client_config(config_path: &str, reason: &str) -> ScaffoldError {
335    ScaffoldError::MalformedClientConfig(format!(
336        "malformed MCP client config {config_path}: {reason}"
337    ))
338}
339
340/// The `asdecided-org` streamable-HTTP server entry for `url` (ADR-117),
341/// insertion-ordered like the oracle's dict literal.
342fn org_server_entry(url: &str) -> serde_json::Value {
343    let mut entry = serde_json::Map::new();
344    entry.insert("type".to_string(), serde_json::Value::String("http".to_string()));
345    entry.insert("url".to_string(), serde_json::Value::String(url.to_string()));
346    serde_json::Value::Object(entry)
347}
348
349/// `write_org_endpoint(directory, url)` — ensure the `asdecided-org` entry in
350/// each client config (profiles.write_org_endpoint, ADR-117): merge into an
351/// existing file touching only the `asdecided-org` key, create absent files,
352/// skip files already carrying the exact entry, and parse every target
353/// before writing any (no partial writes). `serde_json`'s `preserve_order`
354/// keeps user key order exactly as the oracle's `dict` does.
355fn write_org_endpoint(directory: &str, url: &str) -> Result<Vec<String>, ScaffoldError> {
356    let entry = org_server_entry(url);
357    let targets: [&[&str]; 2] = [&[".mcp.json"], &[".cursor", "mcp.json"]];
358    let mut planned: Vec<(String, String)> = Vec::new();
359    for target in targets {
360        let path = py_join(directory, target);
361        if Path::new(&path).is_file() {
362            let text = std::fs::read_to_string(&path)
363                .map_err(|_| malformed_client_config(&path, "not valid JSON"))?;
364            let mut data: serde_json::Value = serde_json::from_str(&text)
365                .map_err(|_| malformed_client_config(&path, "not valid JSON"))?;
366            let obj = data.as_object_mut().ok_or_else(|| {
367                malformed_client_config(&path, "top level must be a JSON object")
368            })?;
369            let servers = obj
370                .entry("mcpServers")
371                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
372            let servers = servers.as_object_mut().ok_or_else(|| {
373                malformed_client_config(&path, "'mcpServers' must be a JSON object")
374            })?;
375            if servers.get(ORG_SERVER_KEY) == Some(&entry) {
376                continue; // already wired to this endpoint: idempotent no-op
377            }
378            servers.insert(ORG_SERVER_KEY.to_string(), entry.clone());
379            planned.push((path, format!("{}\n", crate::pyjson::dumps_indent2_no_ascii(&data))));
380        } else {
381            let mut servers = serde_json::Map::new();
382            servers.insert(ORG_SERVER_KEY.to_string(), entry.clone());
383            let mut payload = serde_json::Map::new();
384            payload.insert("mcpServers".to_string(), serde_json::Value::Object(servers));
385            let payload = serde_json::Value::Object(payload);
386            planned.push((path, format!("{}\n", crate::pyjson::dumps_indent2_no_ascii(&payload))));
387        }
388    }
389    let mut written = Vec::new();
390    for (path, text) in planned {
391        if let Some(parent) = Path::new(&path).parent() {
392            std::fs::create_dir_all(parent)
393                .map_err(|e| malformed_client_config(&path, &e.to_string()))?;
394        }
395        std::fs::write(&path, text).map_err(|e| malformed_client_config(&path, &e.to_string()))?;
396        written.push(path);
397    }
398    Ok(written)
399}
400
401/// Outcome of one `decided init` run (stable JSON contract, ADR-007).
402pub struct InitResult {
403    pub repository_key: String,
404    pub config_path: String,
405    pub created: bool,
406    pub profile: Option<String>,
407    pub files_written: Vec<String>,
408    pub org_endpoint: Option<String>,
409}
410
411/// `init_repository(directory, key, ticketing, profile, org_endpoint)` —
412/// establish (or confirm) the identity namespace. `ticketing` and `profile`
413/// arrive argparse-choice-validated; both apply only on a FRESH init.
414/// `org_endpoint` (ADR-117) is an explicit operator action and applies on
415/// fresh AND already-initialized repositories alike.
416pub fn init_repository(
417    directory: &str,
418    key: &str,
419    ticketing: Option<&str>,
420    profile: Option<&str>,
421    org_endpoint: Option<&str>,
422) -> Result<InitResult, ScaffoldError> {
423    if !valid_repository_key(key) {
424        return Err(invalid_key_error(key));
425    }
426    if let Some(url) = org_endpoint {
427        if !(url.starts_with("http://") || url.starts_with("https://")) {
428            return Err(invalid_org_endpoint(url));
429        }
430    }
431    let config_path = py_join(directory, &[".decided", "config.yaml"]);
432    if Path::new(&config_path).is_file() {
433        let existing = read_config(&config_path)?;
434        if existing.repository_key != key {
435            return Err(ScaffoldError::RepositoryKeyConflict(format!(
436                "repository already initialized with key {} ({config_path}); \
437                 refusing to change it to {} \u{2014} established ID namespaces \
438                 are never silently rewritten",
439                py_repr_str(&existing.repository_key),
440                py_repr_str(key)
441            )));
442        }
443        let org_files = match org_endpoint {
444            Some(url) => write_org_endpoint(directory, url)?,
445            None => Vec::new(),
446        };
447        return Ok(InitResult {
448            repository_key: key.to_string(),
449            config_path,
450            created: false,
451            profile: None,
452            files_written: org_files,
453            org_endpoint: org_endpoint.map(str::to_string),
454        });
455    }
456    let io_err = |e: std::io::Error| malformed_config(&config_path, &format!("invalid YAML: {e}"));
457    if let Some(parent) = Path::new(&config_path).parent() {
458        std::fs::create_dir_all(parent).map_err(io_err)?;
459    }
460    let mut body = format!("repository_key: {key}\n");
461    if let Some(provider) = ticketing {
462        body.push_str(&format!("ticketing:\n  provider: {provider}\n"));
463    }
464    let (stanza, wiring) = profile.map(profile_parts).unwrap_or(("", false));
465    body.push_str(stanza);
466    std::fs::write(&config_path, body).map_err(io_err)?;
467    let mut files_written = if profile.is_some() && wiring {
468        write_mcp_configs(directory).map_err(io_err)?
469    } else {
470        Vec::new()
471    };
472    if let Some(url) = org_endpoint {
473        for path in write_org_endpoint(directory, url)? {
474            if !files_written.contains(&path) {
475                files_written.push(path);
476            }
477        }
478    }
479    Ok(InitResult {
480        repository_key: key.to_string(),
481        config_path,
482        created: true,
483        profile: profile.map(str::to_string),
484        files_written,
485        org_endpoint: org_endpoint.map(str::to_string),
486    })
487}
488
489// ---------------------------------------------------------------------------
490// Artifact creation (decided.services.create)
491// ---------------------------------------------------------------------------
492
493/// Result of one artifact creation (`bytes_written` is in the oracle's
494/// dataclass but deliberately absent from its JSON, so it is not carried).
495pub struct CreatedArtifact {
496    pub artifact_type: String,
497    pub path: String,
498    pub id: String,
499}
500
501/// `str(Path(p))` / `str(Path(p).parent)` — the pathlib shaping used by
502/// the error messages (the SUCCESS path echoes the argv string verbatim).
503fn py_parent(p: &str) -> String {
504    let normalized = crate::walk::normalize_root(p);
505    if normalized == "/" || normalized == "." {
506        return normalized;
507    }
508    match normalized.rfind('/') {
509        Some(0) => "/".to_string(),
510        Some(i) => normalized[..i].to_string(),
511        None => ".".to_string(),
512    }
513}
514
515/// The id-collision set: every discovered artifact's canonical identifier,
516/// uppercased (`{entry.id.upper() for entry in build_repository_index(...)}`).
517///
518/// The oracle CRASHES here when the walk hits hostile markdown (an
519/// unhashable YAML key raises inside frontmatter parsing — the pinned
520/// oracle-crash class); the native walk is total, so hostile files simply
521/// contribute whatever identifier they still yield (RAC-KXBPS7SRM6ZB
522/// REQ-002: creation must succeed).
523fn issued_ids(repository_root: &str) -> HashSet<String> {
524    corpus_items(repository_root, true)
525        .iter()
526        .map(|item| {
527            crate::identity::artifact_identifier(&item.artifact, item.spec, &item.path)
528                .to_uppercase()
529        })
530        .collect()
531}
532
533/// `_assign_id` / migrate's `_next_id` — generate, check, retry bounded.
534fn assign_id(repository_key: &str, issued: &mut HashSet<String>) -> Result<String, ScaffoldError> {
535    for _ in 0..MAX_ID_ATTEMPTS {
536        let candidate = generate_id(repository_key);
537        let upper = candidate.to_uppercase();
538        if !issued.contains(&upper) {
539            issued.insert(upper);
540            return Ok(candidate);
541        }
542    }
543    Err(id_generation_exhausted())
544}
545
546/// `create_artifact(artifact_type, output_path)` — write one new artifact
547/// with assigned identity. The path is taken literally: no slug derivation,
548/// no directory creation, never overwrite.
549pub fn create_artifact(
550    artifact_type: &str,
551    output_path: &str,
552) -> Result<CreatedArtifact, ScaffoldError> {
553    let body = load_template(artifact_type)?; // validates the type first
554    if Path::new(output_path).exists() {
555        return Err(ScaffoldError::OutputPathExists(format!(
556            "{output_path} already exists; decided new never overwrites"
557        )));
558    }
559    let parent = py_parent(output_path);
560    if !Path::new(&parent).is_dir() {
561        return Err(ScaffoldError::OutputDirectoryMissing(format!(
562            "directory does not exist: {parent}"
563        )));
564    }
565    let Some(config) = load_repository_config(&parent)? else {
566        return Err(missing_repository_config(&parent));
567    };
568    // repository_root = str(Path(config_path).parent.parent) — the resolved
569    // config path's grandparent (the directory holding `.decided/`).
570    let repository_root = Path::new(&config.config_path)
571        .parent()
572        .and_then(Path::parent)
573        .map(|p| p.to_string_lossy().into_owned())
574        .unwrap_or_else(|| ".".to_string());
575    let mut issued = issued_ids(&repository_root);
576    let artifact_id = assign_id(&config.repository_key, &mut issued)?;
577    let content = format!("{}{body}", render_frontmatter(&artifact_id, artifact_type));
578    std::fs::write(output_path, content.as_bytes()).map_err(|e| {
579        // The oracle lets a write OSError escape as a traceback (exit 1);
580        // surface the same operational class without the traceback noise.
581        ScaffoldError::MalformedRepositoryConfig(format!("cannot write {output_path}: {e}"))
582    })?;
583    Ok(CreatedArtifact {
584        artifact_type: artifact_type.to_string(),
585        path: output_path.to_string(),
586        id: artifact_id,
587    })
588}
589
590// ---------------------------------------------------------------------------
591// Quickstart (decided.services.quickstart, ADR-044)
592// ---------------------------------------------------------------------------
593
594/// Outcome of one `decided quickstart` run.
595pub struct QuickstartResult {
596    pub repository_key: String,
597    pub config_path: String,
598    pub created: bool,
599    pub artifact: CreatedArtifact,
600}
601
602/// `quickstart(directory, key, artifact_type)` — validate the type first,
603/// refuse a non-empty corpus BEFORE any write, establish identity, then
604/// scaffold `<dir>/decisions/<type>s/first-<type>.md`.
605///
606/// Check order is load-bearing (measured): bad type (exit 2) beats a
607/// non-empty corpus (exit 1) beats a bad key (exit 2 when reached). Note
608/// the identity write lands BEFORE the starter-exists refusal, exactly like
609/// the oracle (`init_repository` precedes `create_artifact`).
610pub fn quickstart(
611    directory: &str,
612    key: &str,
613    artifact_type: &str,
614) -> Result<QuickstartResult, ScaffoldError> {
615    load_template(artifact_type)?; // validate before any side effect
616
617    // Refuse a non-empty corpus: any entry classified as a known type. The
618    // oracle crashes when this walk hits hostile markdown; the native walk
619    // is total (RAC-KXBPS7SRM6ZB REQ-002 class, documented divergence).
620    let items = corpus_items(directory, true);
621    if let Some(existing) = items.iter().find(|item| item.spec.is_some()) {
622        return Err(ScaffoldError::CorpusNotEmpty(format!(
623            "corpus already has artifacts (e.g. {}); decided quickstart only \
624             scaffolds an empty corpus \u{2014} use `decided new` to add more",
625            existing.path
626        )));
627    }
628
629    let init_result = init_repository(directory, key, None, None, None)?;
630
631    let family = format!("{artifact_type}s");
632    let art_dir = py_join(directory, &["decisions", &family]);
633    std::fs::create_dir_all(&art_dir)
634        .map_err(|e| malformed_config(&art_dir, &format!("invalid YAML: {e}")))?;
635    let file_name = format!("first-{artifact_type}.md");
636    let out_path = py_join(directory, &["decisions", &family, &file_name]);
637    let artifact = create_artifact(artifact_type, &out_path)?;
638
639    Ok(QuickstartResult {
640        repository_key: init_result.repository_key,
641        config_path: init_result.config_path,
642        created: init_result.created,
643        artifact,
644    })
645}
646
647// ---------------------------------------------------------------------------
648// Metadata migration (decided.services.migrate, ADR-025)
649// ---------------------------------------------------------------------------
650
651/// Stable per-file statuses (part of the JSON contract, ADR-007).
652pub const STATUS_MIGRATED: &str = "migrated";
653pub const STATUS_ALREADY_CANONICAL: &str = "already-canonical";
654pub const STATUS_SKIPPED_UNKNOWN: &str = "skipped-unknown";
655
656/// Migration outcome for one Markdown file.
657pub struct FileMigration {
658    pub path: String,
659    pub status: &'static str,
660    pub id: Option<String>,
661    pub artifact_type: Option<String>,
662}
663
664/// Repository-level migration result (stable JSON contract, ADR-007).
665pub struct MigrationReport {
666    pub directory: String,
667    pub recursive: bool,
668    pub dry_run: bool,
669    pub files: Vec<FileMigration>,
670}
671
672impl MigrationReport {
673    fn count(&self, status: &str) -> usize {
674        self.files.iter().filter(|f| f.status == status).count()
675    }
676
677    pub fn migrated(&self) -> usize {
678        self.count(STATUS_MIGRATED)
679    }
680
681    pub fn already_canonical(&self) -> usize {
682        self.count(STATUS_ALREADY_CANONICAL)
683    }
684
685    pub fn skipped_unknown(&self) -> usize {
686        self.count(STATUS_SKIPPED_UNKNOWN)
687    }
688}
689
690/// `migrate_metadata(directory, dry_run, recursive)` — prepend the
691/// canonical envelope to every recognized artifact without frontmatter,
692/// body bytes untouched. ANY frontmatter presence — valid, malformed, or
693/// unterminated — is `already-canonical` (validation owns broken
694/// envelopes); documents that do not classify are `skipped-unknown`.
695pub fn migrate_metadata(
696    directory: &str,
697    dry_run: bool,
698    recursive: bool,
699) -> Result<MigrationReport, ScaffoldError> {
700    let Some(config) = load_repository_config(directory)? else {
701        return Err(missing_repository_config(directory));
702    };
703    let repository_root = Path::new(&config.config_path)
704        .parent()
705        .and_then(Path::parent)
706        .map(|p| p.to_string_lossy().into_owned())
707        .unwrap_or_else(|| ".".to_string());
708    let mut issued = issued_ids(&repository_root);
709
710    let mut files = Vec::new();
711    for item in corpus_items(directory, recursive) {
712        if item.artifact.metadata.is_some() || !item.artifact.metadata_issues.is_empty() {
713            files.push(FileMigration {
714                path: item.path.clone(),
715                status: STATUS_ALREADY_CANONICAL,
716                id: None,
717                artifact_type: None,
718            });
719            continue;
720        }
721        let Some(spec) = item.spec else {
722            files.push(FileMigration {
723                path: item.path.clone(),
724                status: STATUS_SKIPPED_UNKNOWN,
725                id: None,
726                artifact_type: None,
727            });
728            continue;
729        };
730        let artifact_id = assign_id(&config.repository_key, &mut issued)?;
731        if !dry_run {
732            // Prepend the envelope only; the body bytes are untouched.
733            let original = std::fs::read(&item.path).map_err(|e| {
734                malformed_config(&item.path, &format!("invalid YAML: {e}"))
735            })?;
736            let mut data = render_frontmatter(&artifact_id, &spec.name).into_bytes();
737            data.extend_from_slice(&original);
738            std::fs::write(&item.path, data).map_err(|e| {
739                malformed_config(&item.path, &format!("invalid YAML: {e}"))
740            })?;
741        }
742        files.push(FileMigration {
743            path: item.path.clone(),
744            status: STATUS_MIGRATED,
745            id: Some(artifact_id),
746            artifact_type: Some(spec.name.clone()),
747        });
748    }
749    Ok(MigrationReport {
750        directory: directory.to_string(),
751        recursive,
752        dry_run,
753        files,
754    })
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn id_shape_is_key_dash_twelve_crockford() {
763        let id = generate_id("RAC");
764        assert!(id.starts_with("RAC-"));
765        let tail = &id[4..];
766        assert_eq!(tail.len(), 12);
767        assert!(tail.bytes().all(|b| ID_ALPHABET.contains(&b)), "{id}");
768    }
769
770    #[test]
771    fn key_contract_edges() {
772        assert!(valid_repository_key("RAC"));
773        assert!(valid_repository_key("AB"));
774        assert!(valid_repository_key("A234567890"));
775        assert!(!valid_repository_key("A"));
776        assert!(!valid_repository_key("ABCDEFGHIJK"));
777        assert!(!valid_repository_key("bad"));
778        assert!(!valid_repository_key("1AB"));
779        // Python `$` matches just before one trailing newline.
780        assert!(valid_repository_key("RAC\n"));
781    }
782
783    /// RAC-KXBPS7SRM6ZB REQ-002: the native `decided new` must succeed when the
784    /// repository walk encounters unparseable/hostile Markdown. The oracle
785    /// CRASHES on this fixture (an unhashable YAML mapping key — a list —
786    /// raises `TypeError` inside `_no_duplicates` during the id-collision
787    /// index walk, measured exit 1 with a traceback); the native walk is
788    /// total, skips the hostile file's broken envelope, and mints an id.
789    #[test]
790    fn new_survives_hostile_markdown_in_the_walk() {
791        let base = std::env::var("CARGO_TARGET_TMPDIR").unwrap_or_else(|_| "/tmp".into());
792        let root = std::path::Path::new(&base)
793            .join(format!("scaffold_hostile_{}", std::process::id()));
794        let _ = std::fs::remove_dir_all(&root);
795        std::fs::create_dir_all(root.join("decisions/decisions")).unwrap();
796        std::fs::create_dir_all(root.join(".decided")).unwrap();
797        std::fs::write(root.join(".decided/config.yaml"), "repository_key: RAC\n").unwrap();
798        // The pinned oracle-crash repro: a YAML mapping with a LIST key.
799        let hostile = format!(
800            "{}/../fuzz/pinned/oracle-crashes/unhashable-key/repro.md",
801            env!("CARGO_MANIFEST_DIR")
802        );
803        let hostile_bytes = std::fs::read(&hostile)
804            .unwrap_or_else(|e| panic!("cannot read {hostile}: {e}"));
805        std::fs::write(root.join("decisions/decisions/case.md"), hostile_bytes).unwrap();
806
807        let out = root.join("decisions/decisions/new.md").to_string_lossy().into_owned();
808        let created = match create_artifact("decision", &out) {
809            Ok(created) => created,
810            Err(e) => panic!("create_artifact failed on a hostile corpus: {}", e.message()),
811        };
812        assert_eq!(created.artifact_type, "decision");
813        let written = std::fs::read_to_string(&out).unwrap();
814        assert!(written.starts_with("---\nschema_version: 1\nid: RAC-"));
815        let _ = std::fs::remove_dir_all(&root);
816    }
817}