use crate::core::schema;
use crate::core::validator;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
fn bundled_version() -> Option<&'static str> {
schema::schema()
.get("properties")
.and_then(|p| p.get("schema_version"))
.and_then(|sv| sv.get("enum"))
.and_then(|e| e.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
}
fn find_project_root(cwd: &Path) -> PathBuf {
let mut current = cwd.to_path_buf();
loop {
if current.join(".notarai").is_dir() {
return current;
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => return cwd.to_path_buf(),
}
}
}
fn project_schema_version(project_root: &Path) -> Option<String> {
let schema_path = project_root.join(".notarai/notarai.spec.json");
let content = std::fs::read_to_string(&schema_path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
if let Some(v) = json
.get("properties")
.and_then(|p| p.get("schema_version"))
.and_then(|sv| sv.get("enum"))
.and_then(|e| e.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
{
return Some(v.to_string());
}
let id = json.get("$id").and_then(|v| v.as_str())?;
id.split('/').rev().nth(1).map(String::from)
}
fn collect_spec_files(notarai_dir: &Path) -> Vec<PathBuf> {
let mut specs = Vec::new();
for entry in WalkDir::new(notarai_dir) {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
if entry
.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".spec.yaml"))
{
specs.push(entry.into_path());
}
}
specs
}
fn update_spec_files(
spec_files: &[PathBuf],
old_version: &str,
new_version: &str,
) -> Result<usize, String> {
let single_old = format!("schema_version: '{old_version}'");
let single_new = format!("schema_version: '{new_version}'");
let double_old = format!("schema_version: \"{old_version}\"");
let double_new = format!("schema_version: \"{new_version}\"");
let mut updated = 0;
for path in spec_files {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
if !content.contains(&single_old) && !content.contains(&double_old) {
continue;
}
let new_content = content
.replace(&single_old, &single_new)
.replace(&double_old, &double_new);
std::fs::write(path, new_content)
.map_err(|e| format!("could not write {}: {e}", path.display()))?;
updated += 1;
}
Ok(updated)
}
pub fn run(project_root: Option<&Path>) -> i32 {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let root = match project_root {
Some(p) => p.to_path_buf(),
None => find_project_root(&cwd),
};
let new_version = match bundled_version() {
Some(v) => v,
None => {
eprintln!("Error: could not determine bundled schema version");
return 1;
}
};
let old_version = project_schema_version(&root);
match &old_version {
Some(v) if v == new_version => {
println!("Already at current schema version ({new_version})");
return 0;
}
Some(v) => println!("Updating schema from {v} to {new_version}"),
None => println!("No local schema found, installing version {new_version}"),
}
let notarai_dir = root.join(".notarai");
if !notarai_dir.exists()
&& let Err(e) = std::fs::create_dir_all(¬arai_dir)
{
eprintln!("Error: could not create .notarai/ directory: {e}");
return 1;
}
let schema_dest = notarai_dir.join("notarai.spec.json");
if let Err(e) = std::fs::write(&schema_dest, crate::core::schema::SCHEMA_STR) {
eprintln!("Error: could not write .notarai/notarai.spec.json: {e}");
return 1;
}
let spec_files = collect_spec_files(¬arai_dir);
if spec_files.is_empty() {
println!("No spec files found in .notarai/");
return 0;
}
let old = old_version.as_deref().unwrap_or("0.0");
let updated = match update_spec_files(&spec_files, old, new_version) {
Ok(n) => n,
Err(e) => {
eprintln!("Error: {e}");
return 1;
}
};
let mut has_failure = false;
for path in &spec_files {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("FAIL {}: {e}", path.display());
has_failure = true;
continue;
}
};
let result = validator::validate_spec(&content);
if !result.valid {
eprintln!("FAIL {}", path.display());
for err in &result.errors {
eprintln!(" {err}");
}
has_failure = true;
}
}
if has_failure {
eprintln!("Validation failed after schema bump -- please review the errors above");
return 1;
}
println!(
"Updated {updated} spec file(s) from {} to {new_version}",
old
);
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_version_is_current() {
let v = bundled_version().expect("bundled version present");
assert!(!v.is_empty());
}
#[test]
fn update_spec_files_handles_single_quotes() {
let tmp = tempfile::TempDir::new().unwrap();
let spec = tmp.path().join("test.spec.yaml");
std::fs::write(
&spec,
"schema_version: '0.4'\nintent: 'test'\nbehaviors: []\nartifacts: {}\n",
)
.unwrap();
let updated = update_spec_files(&[spec.clone()], "0.4", "0.5").unwrap();
assert_eq!(updated, 1);
let content = std::fs::read_to_string(&spec).unwrap();
assert!(content.contains("schema_version: '0.5'"));
}
#[test]
fn update_spec_files_handles_double_quotes() {
let tmp = tempfile::TempDir::new().unwrap();
let spec = tmp.path().join("test.spec.yaml");
std::fs::write(
&spec,
"schema_version: \"0.4\"\nintent: 'test'\nbehaviors: []\nartifacts: {}\n",
)
.unwrap();
let updated = update_spec_files(&[spec.clone()], "0.4", "0.5").unwrap();
assert_eq!(updated, 1);
let content = std::fs::read_to_string(&spec).unwrap();
assert!(content.contains("schema_version: \"0.5\""));
}
}