use std::collections::HashSet;
use std::path::Path;
use crate::pycompat::py_repr_str;
use crate::relationships::corpus_items;
use crate::spec::available_schemas;
use crate::validate::find_config_file;
use crate::walk::py_join;
pub enum ScaffoldError {
TemplateNotFound(String),
OutputPathExists(String),
OutputDirectoryMissing(String),
MissingRepositoryConfig(String),
InvalidRepositoryKey(String),
RepositoryKeyConflict(String),
MalformedRepositoryConfig(String),
IdGenerationExhausted(String),
CorpusNotEmpty(String),
InvalidOrgEndpoint(String),
MalformedClientConfig(String),
}
impl ScaffoldError {
pub fn message(&self) -> &str {
match self {
ScaffoldError::TemplateNotFound(m)
| ScaffoldError::OutputPathExists(m)
| ScaffoldError::OutputDirectoryMissing(m)
| ScaffoldError::MissingRepositoryConfig(m)
| ScaffoldError::InvalidRepositoryKey(m)
| ScaffoldError::RepositoryKeyConflict(m)
| ScaffoldError::MalformedRepositoryConfig(m)
| ScaffoldError::IdGenerationExhausted(m)
| ScaffoldError::CorpusNotEmpty(m)
| ScaffoldError::InvalidOrgEndpoint(m)
| ScaffoldError::MalformedClientConfig(m) => m,
}
}
}
fn template_not_found(artifact_type: &str) -> ScaffoldError {
ScaffoldError::TemplateNotFound(format!(
"unsupported artifact type: {artifact_type} (supported: {})",
available_schemas().join(", ")
))
}
fn missing_repository_config(start_dir: &str) -> ScaffoldError {
ScaffoldError::MissingRepositoryConfig(format!(
"no repository identity found at or above {start_dir}; \
run `decided init` to establish a repository key first"
))
}
fn malformed_config(config_path: &str, reason: &str) -> ScaffoldError {
ScaffoldError::MalformedRepositoryConfig(format!(
"malformed repository config {config_path}: {reason}"
))
}
fn id_generation_exhausted() -> ScaffoldError {
ScaffoldError::IdGenerationExhausted(format!(
"could not generate a unique artifact ID in {MAX_ID_ATTEMPTS} attempts"
))
}
pub const ID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const TIME_CHARS: usize = 8; const RANDOM_CHARS: usize = 4;
const MAX_ID_ATTEMPTS: usize = 5;
fn encode_base32(mut value: u64, chars: usize) -> String {
let mut out = vec![0u8; chars];
for slot in out.iter_mut().rev() {
*slot = ID_ALPHABET[(value & 0x1F) as usize];
value >>= 5;
}
String::from_utf8(out).expect("alphabet is ASCII")
}
fn random_bits_20() -> u64 {
use std::io::Read;
let mut buf = [0u8; 4];
if std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.is_ok()
{
return (u64::from(u32::from_le_bytes(buf))) & 0xF_FFFF;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
(nanos ^ u64::from(std::process::id())) & 0xF_FFFF
}
pub fn generate_id(repository_key: &str) -> String {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
& ((1 << (TIME_CHARS * 5)) - 1);
format!(
"{repository_key}-{}{}",
encode_base32(millis, TIME_CHARS),
encode_base32(random_bits_20(), RANDOM_CHARS)
)
}
const TEMPLATE_BYTES: [&str; 5] = [
include_str!("../assets/templates/requirement.md"),
include_str!("../assets/templates/decision.md"),
include_str!("../assets/templates/roadmap.md"),
include_str!("../assets/templates/prompt.md"),
include_str!("../assets/templates/design.md"),
];
pub fn load_template(artifact_type: &str) -> Result<&'static str, ScaffoldError> {
available_schemas()
.iter()
.position(|name| *name == artifact_type)
.map(|i| TEMPLATE_BYTES[i])
.ok_or_else(|| template_not_found(artifact_type))
}
pub fn render_frontmatter(artifact_id: &str, artifact_type: &str) -> String {
format!("---\nschema_version: 1\nid: {artifact_id}\ntype: {artifact_type}\n---\n")
}
fn valid_repository_key(key: &str) -> bool {
let core = key.strip_suffix('\n').unwrap_or(key);
let b = core.as_bytes();
(2..=10).contains(&b.len())
&& b[0].is_ascii_uppercase()
&& b.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
}
fn invalid_key_error(key: &str) -> ScaffoldError {
ScaffoldError::InvalidRepositoryKey(format!(
"invalid repository key: {} (expected 2-10 uppercase \
alphanumeric characters starting with a letter, e.g. RAC)",
py_repr_str(key)
))
}
pub struct RepositoryConfig {
pub repository_key: String,
pub config_path: String,
}
fn read_config(config_path: &str) -> Result<RepositoryConfig, ScaffoldError> {
let text = std::fs::read_to_string(config_path)
.map_err(|e| malformed_config(config_path, &format!("invalid YAML: {e}")))?;
let data = crate::frontmatter::yaml_load_config(&text)
.map_err(|problem| malformed_config(config_path, &format!("invalid YAML: {problem}")))?;
let key = match &data {
crate::frontmatter::Yaml::Map(pairs) => pairs.iter().find_map(|(k, v)| match (k, v) {
(crate::frontmatter::Yaml::Str(name), crate::frontmatter::Yaml::Str(value))
if name == "repository_key" =>
{
Some(value.clone())
}
_ => None,
}),
_ => None,
};
let Some(key) = key else {
return Err(malformed_config(
config_path,
"missing required string field 'repository_key'",
));
};
if !valid_repository_key(&key) {
return Err(malformed_config(
config_path,
&format!("invalid repository_key: {}", py_repr_str(&key)),
));
}
Ok(RepositoryConfig {
repository_key: key,
config_path: config_path.to_string(),
})
}
pub fn load_repository_config(start_dir: &str) -> Result<Option<RepositoryConfig>, ScaffoldError> {
match find_config_file(start_dir) {
Some(path) => read_config(&path.to_string_lossy()).map(Some),
None => Ok(None),
}
}
pub const MCP_JSON: &str = "{\n \"mcpServers\": {\n \"asdecided\": {\n \"command\": \"decided-mcp\",\n \"args\": [\"--root\", \".\"]\n }\n }\n}\n";
const ENTERPRISE_CONFIG: &str = "\
# Enterprise profile (ADR-088): relationship-integrity findings block `decided gate`,
# committed explicitly so the enforcement policy is auditable (ADR-049).
enforcement:
blocking:
- relationship-target-not-found
- relationship-target-ambiguous
- relationship-self-reference
- relationship-target-type-mismatch
- relationship-target-superseded
- relationship-cycle
- relationship-edge-unsupported
- duplicate-artifact-identifier
";
fn profile_parts(profile: &str) -> (&'static str, bool) {
match profile {
"enterprise" => (ENTERPRISE_CONFIG, true),
_ => ("", true), }
}
fn write_mcp_configs(directory: &str) -> std::io::Result<Vec<String>> {
let targets: [&[&str]; 2] = [&[".mcp.json"], &[".cursor", "mcp.json"]];
let mut written = Vec::new();
for target in targets {
let path = py_join(directory, target);
if Path::new(&path).exists() {
continue;
}
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, MCP_JSON)?;
written.push(path);
}
Ok(written)
}
const ORG_SERVER_KEY: &str = "lore-org";
fn invalid_org_endpoint(url: &str) -> ScaffoldError {
ScaffoldError::InvalidOrgEndpoint(format!(
"invalid org endpoint: {} (expected an http:// or https:// URL, \
e.g. https://lore.example.com/mcp)",
py_repr_str(url)
))
}
fn malformed_client_config(config_path: &str, reason: &str) -> ScaffoldError {
ScaffoldError::MalformedClientConfig(format!(
"malformed MCP client config {config_path}: {reason}"
))
}
fn org_server_entry(url: &str) -> serde_json::Value {
let mut entry = serde_json::Map::new();
entry.insert("type".to_string(), serde_json::Value::String("http".to_string()));
entry.insert("url".to_string(), serde_json::Value::String(url.to_string()));
serde_json::Value::Object(entry)
}
fn write_org_endpoint(directory: &str, url: &str) -> Result<Vec<String>, ScaffoldError> {
let entry = org_server_entry(url);
let targets: [&[&str]; 2] = [&[".mcp.json"], &[".cursor", "mcp.json"]];
let mut planned: Vec<(String, String)> = Vec::new();
for target in targets {
let path = py_join(directory, target);
if Path::new(&path).is_file() {
let text = std::fs::read_to_string(&path)
.map_err(|_| malformed_client_config(&path, "not valid JSON"))?;
let mut data: serde_json::Value = serde_json::from_str(&text)
.map_err(|_| malformed_client_config(&path, "not valid JSON"))?;
let obj = data.as_object_mut().ok_or_else(|| {
malformed_client_config(&path, "top level must be a JSON object")
})?;
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let servers = servers.as_object_mut().ok_or_else(|| {
malformed_client_config(&path, "'mcpServers' must be a JSON object")
})?;
if servers.get(ORG_SERVER_KEY) == Some(&entry) {
continue; }
servers.insert(ORG_SERVER_KEY.to_string(), entry.clone());
planned.push((path, format!("{}\n", crate::pyjson::dumps_indent2_no_ascii(&data))));
} else {
let mut servers = serde_json::Map::new();
servers.insert(ORG_SERVER_KEY.to_string(), entry.clone());
let mut payload = serde_json::Map::new();
payload.insert("mcpServers".to_string(), serde_json::Value::Object(servers));
let payload = serde_json::Value::Object(payload);
planned.push((path, format!("{}\n", crate::pyjson::dumps_indent2_no_ascii(&payload))));
}
}
let mut written = Vec::new();
for (path, text) in planned {
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| malformed_client_config(&path, &e.to_string()))?;
}
std::fs::write(&path, text).map_err(|e| malformed_client_config(&path, &e.to_string()))?;
written.push(path);
}
Ok(written)
}
pub struct InitResult {
pub repository_key: String,
pub config_path: String,
pub created: bool,
pub profile: Option<String>,
pub files_written: Vec<String>,
pub org_endpoint: Option<String>,
}
pub fn init_repository(
directory: &str,
key: &str,
ticketing: Option<&str>,
profile: Option<&str>,
org_endpoint: Option<&str>,
) -> Result<InitResult, ScaffoldError> {
if !valid_repository_key(key) {
return Err(invalid_key_error(key));
}
if let Some(url) = org_endpoint {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(invalid_org_endpoint(url));
}
}
let config_path = py_join(directory, &[".decided", "config.yaml"]);
if Path::new(&config_path).is_file() {
let existing = read_config(&config_path)?;
if existing.repository_key != key {
return Err(ScaffoldError::RepositoryKeyConflict(format!(
"repository already initialized with key {} ({config_path}); \
refusing to change it to {} \u{2014} established ID namespaces \
are never silently rewritten",
py_repr_str(&existing.repository_key),
py_repr_str(key)
)));
}
let org_files = match org_endpoint {
Some(url) => write_org_endpoint(directory, url)?,
None => Vec::new(),
};
return Ok(InitResult {
repository_key: key.to_string(),
config_path,
created: false,
profile: None,
files_written: org_files,
org_endpoint: org_endpoint.map(str::to_string),
});
}
let io_err = |e: std::io::Error| malformed_config(&config_path, &format!("invalid YAML: {e}"));
if let Some(parent) = Path::new(&config_path).parent() {
std::fs::create_dir_all(parent).map_err(io_err)?;
}
let mut body = format!("repository_key: {key}\n");
if let Some(provider) = ticketing {
body.push_str(&format!("ticketing:\n provider: {provider}\n"));
}
let (stanza, wiring) = profile.map(profile_parts).unwrap_or(("", false));
body.push_str(stanza);
std::fs::write(&config_path, body).map_err(io_err)?;
let mut files_written = if profile.is_some() && wiring {
write_mcp_configs(directory).map_err(io_err)?
} else {
Vec::new()
};
if let Some(url) = org_endpoint {
for path in write_org_endpoint(directory, url)? {
if !files_written.contains(&path) {
files_written.push(path);
}
}
}
Ok(InitResult {
repository_key: key.to_string(),
config_path,
created: true,
profile: profile.map(str::to_string),
files_written,
org_endpoint: org_endpoint.map(str::to_string),
})
}
pub struct CreatedArtifact {
pub artifact_type: String,
pub path: String,
pub id: String,
}
fn py_parent(p: &str) -> String {
let normalized = crate::walk::normalize_root(p);
if normalized == "/" || normalized == "." {
return normalized;
}
match normalized.rfind('/') {
Some(0) => "/".to_string(),
Some(i) => normalized[..i].to_string(),
None => ".".to_string(),
}
}
fn issued_ids(repository_root: &str) -> HashSet<String> {
corpus_items(repository_root, true)
.iter()
.map(|item| {
crate::identity::artifact_identifier(&item.artifact, item.spec, &item.path)
.to_uppercase()
})
.collect()
}
fn assign_id(repository_key: &str, issued: &mut HashSet<String>) -> Result<String, ScaffoldError> {
for _ in 0..MAX_ID_ATTEMPTS {
let candidate = generate_id(repository_key);
let upper = candidate.to_uppercase();
if !issued.contains(&upper) {
issued.insert(upper);
return Ok(candidate);
}
}
Err(id_generation_exhausted())
}
pub fn create_artifact(
artifact_type: &str,
output_path: &str,
) -> Result<CreatedArtifact, ScaffoldError> {
let body = load_template(artifact_type)?; if Path::new(output_path).exists() {
return Err(ScaffoldError::OutputPathExists(format!(
"{output_path} already exists; decided new never overwrites"
)));
}
let parent = py_parent(output_path);
if !Path::new(&parent).is_dir() {
return Err(ScaffoldError::OutputDirectoryMissing(format!(
"directory does not exist: {parent}"
)));
}
let Some(config) = load_repository_config(&parent)? else {
return Err(missing_repository_config(&parent));
};
let repository_root = Path::new(&config.config_path)
.parent()
.and_then(Path::parent)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| ".".to_string());
let mut issued = issued_ids(&repository_root);
let artifact_id = assign_id(&config.repository_key, &mut issued)?;
let content = format!("{}{body}", render_frontmatter(&artifact_id, artifact_type));
std::fs::write(output_path, content.as_bytes()).map_err(|e| {
ScaffoldError::MalformedRepositoryConfig(format!("cannot write {output_path}: {e}"))
})?;
Ok(CreatedArtifact {
artifact_type: artifact_type.to_string(),
path: output_path.to_string(),
id: artifact_id,
})
}
pub struct QuickstartResult {
pub repository_key: String,
pub config_path: String,
pub created: bool,
pub artifact: CreatedArtifact,
}
pub fn quickstart(
directory: &str,
key: &str,
artifact_type: &str,
) -> Result<QuickstartResult, ScaffoldError> {
load_template(artifact_type)?;
let items = corpus_items(directory, true);
if let Some(existing) = items.iter().find(|item| item.spec.is_some()) {
return Err(ScaffoldError::CorpusNotEmpty(format!(
"corpus already has artifacts (e.g. {}); decided quickstart only \
scaffolds an empty corpus \u{2014} use `decided new` to add more",
existing.path
)));
}
let init_result = init_repository(directory, key, None, None, None)?;
let family = format!("{artifact_type}s");
let art_dir = py_join(directory, &["decisions", &family]);
std::fs::create_dir_all(&art_dir)
.map_err(|e| malformed_config(&art_dir, &format!("invalid YAML: {e}")))?;
let file_name = format!("first-{artifact_type}.md");
let out_path = py_join(directory, &["decisions", &family, &file_name]);
let artifact = create_artifact(artifact_type, &out_path)?;
Ok(QuickstartResult {
repository_key: init_result.repository_key,
config_path: init_result.config_path,
created: init_result.created,
artifact,
})
}
pub const STATUS_MIGRATED: &str = "migrated";
pub const STATUS_ALREADY_CANONICAL: &str = "already-canonical";
pub const STATUS_SKIPPED_UNKNOWN: &str = "skipped-unknown";
pub struct FileMigration {
pub path: String,
pub status: &'static str,
pub id: Option<String>,
pub artifact_type: Option<String>,
}
pub struct MigrationReport {
pub directory: String,
pub recursive: bool,
pub dry_run: bool,
pub files: Vec<FileMigration>,
}
impl MigrationReport {
fn count(&self, status: &str) -> usize {
self.files.iter().filter(|f| f.status == status).count()
}
pub fn migrated(&self) -> usize {
self.count(STATUS_MIGRATED)
}
pub fn already_canonical(&self) -> usize {
self.count(STATUS_ALREADY_CANONICAL)
}
pub fn skipped_unknown(&self) -> usize {
self.count(STATUS_SKIPPED_UNKNOWN)
}
}
pub fn migrate_metadata(
directory: &str,
dry_run: bool,
recursive: bool,
) -> Result<MigrationReport, ScaffoldError> {
let Some(config) = load_repository_config(directory)? else {
return Err(missing_repository_config(directory));
};
let repository_root = Path::new(&config.config_path)
.parent()
.and_then(Path::parent)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| ".".to_string());
let mut issued = issued_ids(&repository_root);
let mut files = Vec::new();
for item in corpus_items(directory, recursive) {
if item.artifact.metadata.is_some() || !item.artifact.metadata_issues.is_empty() {
files.push(FileMigration {
path: item.path.clone(),
status: STATUS_ALREADY_CANONICAL,
id: None,
artifact_type: None,
});
continue;
}
let Some(spec) = item.spec else {
files.push(FileMigration {
path: item.path.clone(),
status: STATUS_SKIPPED_UNKNOWN,
id: None,
artifact_type: None,
});
continue;
};
let artifact_id = assign_id(&config.repository_key, &mut issued)?;
if !dry_run {
let original = std::fs::read(&item.path).map_err(|e| {
malformed_config(&item.path, &format!("invalid YAML: {e}"))
})?;
let mut data = render_frontmatter(&artifact_id, &spec.name).into_bytes();
data.extend_from_slice(&original);
std::fs::write(&item.path, data).map_err(|e| {
malformed_config(&item.path, &format!("invalid YAML: {e}"))
})?;
}
files.push(FileMigration {
path: item.path.clone(),
status: STATUS_MIGRATED,
id: Some(artifact_id),
artifact_type: Some(spec.name.clone()),
});
}
Ok(MigrationReport {
directory: directory.to_string(),
recursive,
dry_run,
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_shape_is_key_dash_twelve_crockford() {
let id = generate_id("RAC");
assert!(id.starts_with("RAC-"));
let tail = &id[4..];
assert_eq!(tail.len(), 12);
assert!(tail.bytes().all(|b| ID_ALPHABET.contains(&b)), "{id}");
}
#[test]
fn key_contract_edges() {
assert!(valid_repository_key("RAC"));
assert!(valid_repository_key("AB"));
assert!(valid_repository_key("A234567890"));
assert!(!valid_repository_key("A"));
assert!(!valid_repository_key("ABCDEFGHIJK"));
assert!(!valid_repository_key("bad"));
assert!(!valid_repository_key("1AB"));
assert!(valid_repository_key("RAC\n"));
}
#[test]
fn new_survives_hostile_markdown_in_the_walk() {
let base = std::env::var("CARGO_TARGET_TMPDIR").unwrap_or_else(|_| "/tmp".into());
let root = std::path::Path::new(&base)
.join(format!("scaffold_hostile_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("decisions/decisions")).unwrap();
std::fs::create_dir_all(root.join(".decided")).unwrap();
std::fs::write(root.join(".decided/config.yaml"), "repository_key: RAC\n").unwrap();
let hostile = format!(
"{}/../fuzz/pinned/oracle-crashes/unhashable-key/repro.md",
env!("CARGO_MANIFEST_DIR")
);
let hostile_bytes = std::fs::read(&hostile)
.unwrap_or_else(|e| panic!("cannot read {hostile}: {e}"));
std::fs::write(root.join("decisions/decisions/case.md"), hostile_bytes).unwrap();
let out = root.join("decisions/decisions/new.md").to_string_lossy().into_owned();
let created = match create_artifact("decision", &out) {
Ok(created) => created,
Err(e) => panic!("create_artifact failed on a hostile corpus: {}", e.message()),
};
assert_eq!(created.artifact_type, "decision");
let written = std::fs::read_to_string(&out).unwrap();
assert!(written.starts_with("---\nschema_version: 1\nid: RAC-"));
let _ = std::fs::remove_dir_all(&root);
}
}