1use 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
29pub enum ScaffoldError {
39 TemplateNotFound(String),
41 OutputPathExists(String),
43 OutputDirectoryMissing(String),
45 MissingRepositoryConfig(String),
47 InvalidRepositoryKey(String),
49 RepositoryKeyConflict(String),
51 MalformedRepositoryConfig(String),
53 IdGenerationExhausted(String),
55 CorpusNotEmpty(String),
57 InvalidOrgEndpoint(String),
59 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
107pub const ID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
113
114const TIME_CHARS: usize = 8; const RANDOM_CHARS: usize = 4; const 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
129fn 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
147pub 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
162const 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
176pub 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
188pub 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
194fn 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
216pub struct RepositoryConfig {
218 pub repository_key: String,
219 pub config_path: String,
220}
221
222fn 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
260pub 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
269pub const MCP_JSON: &str = "{\n \"mcpServers\": {\n \"asdecided\": {\n \"command\": \"decided-mcp\",\n \"args\": [\"--root\", \".\"]\n }\n }\n}\n";
276
277const 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
294fn profile_parts(profile: &str) -> (&'static str, bool) {
298 match profile {
299 "enterprise" => (ENTERPRISE_CONFIG, true),
300 _ => ("", true), }
302}
303
304fn 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
323const 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
340fn 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
349fn 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; }
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
401pub 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
411pub 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
489pub struct CreatedArtifact {
496 pub artifact_type: String,
497 pub path: String,
498 pub id: String,
499}
500
501fn 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
515fn 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
533fn 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
546pub fn create_artifact(
550 artifact_type: &str,
551 output_path: &str,
552) -> Result<CreatedArtifact, ScaffoldError> {
553 let body = load_template(artifact_type)?; 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 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 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
590pub struct QuickstartResult {
596 pub repository_key: String,
597 pub config_path: String,
598 pub created: bool,
599 pub artifact: CreatedArtifact,
600}
601
602pub fn quickstart(
611 directory: &str,
612 key: &str,
613 artifact_type: &str,
614) -> Result<QuickstartResult, ScaffoldError> {
615 load_template(artifact_type)?; 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
647pub const STATUS_MIGRATED: &str = "migrated";
653pub const STATUS_ALREADY_CANONICAL: &str = "already-canonical";
654pub const STATUS_SKIPPED_UNKNOWN: &str = "skipped-unknown";
655
656pub struct FileMigration {
658 pub path: String,
659 pub status: &'static str,
660 pub id: Option<String>,
661 pub artifact_type: Option<String>,
662}
663
664pub 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
690pub 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 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 assert!(valid_repository_key("RAC\n"));
781 }
782
783 #[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 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}