use crate::repair_transaction::{FileRepair, RepairTransaction};
use noxid_agent_planning::{
CatalogKind, Constraint, ContextRequest, DescribeQuery, FeatureKind, FeatureSpec,
MachineContract, MachineTransition, MachineVariant, ManifestProjection, ResourceContract,
build_context_pack, build_manifest, describe, describe_catalog, plan_feature_scaffold,
plan_goal,
};
use noxid_ai_eval::{
IndexFreshness, IntentDriftReport, RepairCompilation, RepairCompiler, RepairOperation,
SafeRepairPlan, SemanticIndex, WorkflowRequest, check_intent_drift, execute_safe_repairs,
plan_safe_repairs, plan_safe_repairs_in, select_affected_scenarios, simulate_workflow,
};
use noxid_formatter::format_source;
use noxid_graph::ApplicationGraph;
use noxid_ir::{SemanticId, SemanticProgram};
use noxid_source::{Diagnostic, SourceFile, SourceId, json_escape};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
struct CompilerProducts {
graph: ApplicationGraph,
programs: Vec<SemanticProgram>,
diagnostics: Vec<Diagnostic>,
}
pub fn run(command: &str, input: &Path, args: impl Iterator<Item = String>) -> Result<(), String> {
match command {
"plan" => run_plan(input, args.collect()),
"context" => run_context(input, args.collect()),
"manifest" => run_manifest(input, args.collect()),
"simulate" => run_simulate(input, args.collect()),
"test-affected" => run_affected_tests(input, args.collect()),
"index" => run_index(input, args.collect()),
"search" => run_search(input, args.collect()),
"repair" => run_repair(input, args.collect()),
"scaffold" => run_scaffold(input, args.collect()),
_ => Err(format!("unknown AI compiler command `{command}`")),
}
}
pub fn run_describe(mut args: impl Iterator<Item = String>) -> Result<(), String> {
let first = args
.next()
.ok_or("noxid describe requires feature, operation, type, diagnostic, or guide")?;
if first == "guide" {
let topic = args.next().ok_or("noxid describe guide requires a topic")?;
if args.next().is_some() {
return Err("noxid describe guide accepts one topic".into());
}
println!("{}", crate::agent_guide(&topic)?);
return Ok(());
}
let kind = parse_catalog_kind(&first)?;
let name = args.next();
if args.next().is_some() {
return Err("noxid describe accepts one optional catalog name".into());
}
if let Some(name) = name {
let entry = describe(&DescribeQuery { kind, name: &name })
.ok_or_else(|| format!("unknown {} `{name}`", kind.as_str()))?;
println!("{}", entry.to_json());
} else {
let entries = describe_catalog(Some(kind))
.into_iter()
.map(|entry| entry.to_json())
.collect::<Vec<_>>()
.join(",");
println!("{{\"schemaVersion\":1,\"entries\":[{entries}]}}");
}
Ok(())
}
pub fn run_drift(before: &Path, after: &Path) -> Result<(), String> {
let report = intent_drift(before, after)?;
println!("{}", report.to_json());
if report.has_errors() {
Err("compiler-visible intent drift contains error findings".into())
} else {
Ok(())
}
}
fn intent_drift(before: &Path, after: &Path) -> Result<IntentDriftReport, String> {
let before = compiler_products(before)?;
let after = compiler_products(after)?;
ensure_valid(&before).map_err(|error| format!("invalid before input: {error}"))?;
ensure_valid(&after).map_err(|error| format!("invalid after input: {error}"))?;
Ok(check_intent_drift(
&merge_program_components(&before.programs),
&merge_program_components(&after.programs),
))
}
fn merge_program_components(programs: &[SemanticProgram]) -> SemanticProgram {
let mut components = programs
.iter()
.flat_map(|program| program.components.iter().cloned())
.collect::<Vec<_>>();
components.sort_by(|left, right| left.id.cmp(&right.id));
components.dedup_by(|left, right| left.id == right.id);
SemanticProgram {
imports: vec![],
functions: vec![],
external_modules: vec![],
contexts: vec![],
types: vec![],
distinct_types: vec![],
resources: vec![],
streams: vec![],
agents: vec![],
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components,
}
}
fn run_plan(input: &Path, args: Vec<String>) -> Result<(), String> {
let products = compiler_products(input)?;
ensure_valid(&products)?;
let mut goal_parts = Vec::new();
let mut request = noxid_agent_planning::GoalRequest::new("");
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--constraint" => {
request.constraints.push(Constraint::required(value_after(
&args,
&mut index,
"--constraint",
)?));
}
"--symbol" => request
.preferred_symbols
.push(value_after(&args, &mut index, "--symbol")?),
"--max-alternatives" => {
request.max_alternatives = parse_usize(
&value_after(&args, &mut index, "--max-alternatives")?,
"--max-alternatives",
)?;
}
option if option.starts_with('-') => {
return Err(format!("unknown plan option `{option}`"));
}
part => goal_parts.push(part.to_string()),
}
index += 1;
}
if goal_parts.is_empty() {
return Err("noxid plan requires a natural-language goal".into());
}
request.goal = goal_parts.join(" ");
println!("{}", plan_goal(&products.graph, &request).to_json());
Ok(())
}
fn run_context(input: &Path, args: Vec<String>) -> Result<(), String> {
let products = compiler_products(input)?;
ensure_valid(&products)?;
let mut task = Vec::new();
let mut request = ContextRequest::new("");
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--max-bytes" => {
request.max_bytes = parse_usize(
&value_after(&args, &mut index, "--max-bytes")?,
"--max-bytes",
)?;
}
"--max-nodes" => {
request.max_nodes = parse_usize(
&value_after(&args, &mut index, "--max-nodes")?,
"--max-nodes",
)?;
}
"--no-edges" => request.include_edges = false,
option if option.starts_with('-') => {
return Err(format!("unknown context option `{option}`"));
}
part => task.push(part.to_string()),
}
index += 1;
}
if task.is_empty() {
return Err("noxid context requires a task description".into());
}
request.task = task.join(" ");
println!(
"{}",
build_context_pack(&products.graph, &request).to_json()
);
Ok(())
}
fn run_manifest(input: &Path, args: Vec<String>) -> Result<(), String> {
let products = compiler_products(input)?;
ensure_valid(&products)?;
let mut projection = ManifestProjection::Compact;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--projection" => {
projection = parse_projection(&value_after(&args, &mut index, "--projection")?)?;
}
other => return Err(format!("unknown manifest option `{other}`")),
}
index += 1;
}
println!("{}", build_manifest(&products.graph, projection).to_json());
Ok(())
}
fn run_simulate(input: &Path, args: Vec<String>) -> Result<(), String> {
let products = compiler_products(input)?;
ensure_valid(&products)?;
let component = args
.first()
.ok_or("noxid simulate requires a component semantic ID")?;
let component = SemanticId::parse(component)
.filter(|id| id.as_str().starts_with("component:"))
.ok_or("simulate component must resemble component:Checkout")?;
let mut actions = Vec::new();
let mut states = BTreeMap::new();
let mut index = 1;
while index < args.len() {
if args[index] == "--state" {
let value = value_after(&args, &mut index, "--state")?;
let (machine, variant) = value
.split_once('=')
.ok_or("--state requires machine-id=variant-id")?;
states.insert(parse_id(machine, "machine")?, parse_id(variant, "variant")?);
} else if args[index].starts_with('-') {
return Err(format!("unknown simulate option `{}`", args[index]));
} else {
actions.push(parse_id(&args[index], "action")?);
}
index += 1;
}
if actions.is_empty() {
return Err("noxid simulate requires at least one action semantic ID".into());
}
let program = products
.programs
.iter()
.find(|program| {
program
.components
.iter()
.any(|candidate| candidate.id == component)
})
.ok_or_else(|| format!("unknown component `{component}`"))?;
let result = simulate_workflow(
program,
&products.graph,
&WorkflowRequest {
component,
actions,
initial_machine_states: states,
},
);
println!("{}", result.to_json());
Ok(())
}
fn run_affected_tests(input: &Path, args: Vec<String>) -> Result<(), String> {
let products = compiler_products(input)?;
ensure_valid(&products)?;
if args.is_empty() {
return Err("noxid test-affected requires one or more stable semantic IDs".into());
}
let changed = args
.iter()
.map(|value| {
SemanticId::parse(value)
.ok_or_else(|| format!("invalid affected-test semantic ID `{value}`"))
})
.collect::<Result<Vec<_>, _>>()?;
let selection = select_affected_scenarios(&products.graph, &changed);
let selected = selection
.scenarios
.iter()
.map(|scenario| scenario.id.clone())
.collect();
let execution = crate::scenario_test::execute_selected_report(
input,
&crate::scenario_test::Options {
gate: false,
json_only: true,
},
&selected,
)?;
println!(
"{}",
crate::scenario_test::affected_report_json(&selection, &execution)
);
if execution.success {
Ok(())
} else {
Err("one or more affected emitted-artifact scenarios failed".into())
}
}
fn run_index(input: &Path, args: Vec<String>) -> Result<(), String> {
if !args.is_empty() {
return Err("noxid index accepts exactly one file or project".into());
}
let products = compiler_products(input)?;
ensure_valid(&products)?;
let path = index_path(input)?;
let index = SemanticIndex::from_graph(&products.graph);
index
.persist_local(&path)
.map_err(|error| error.to_string())?;
println!(
"{{\"schemaVersion\":1,\"persisted\":true,\"path\":\"{}\",\"snapshot\":\"{}\",\"symbols\":{},\"relations\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&index.snapshot),
index.symbols.len(),
index.relations.len(),
);
Ok(())
}
fn run_search(input: &Path, args: Vec<String>) -> Result<(), String> {
let mut query = Vec::new();
let mut limit = 25usize;
let mut cursor = 0;
while cursor < args.len() {
if args[cursor] == "--limit" {
limit = parse_usize(&value_after(&args, &mut cursor, "--limit")?, "--limit")?;
} else if args[cursor].starts_with('-') {
return Err(format!("unknown search option `{}`", args[cursor]));
} else {
query.push(args[cursor].clone());
}
cursor += 1;
}
if query.is_empty() {
return Err("noxid search requires a semantic search query".into());
}
let products = compiler_products(input)?;
ensure_valid(&products)?;
let path = index_path(input)?;
let semantic_index = match SemanticIndex::read_local(&path) {
Ok(index) if index.freshness(&products.graph) == IndexFreshness::Current => index,
Ok(_) | Err(_) => {
let index = SemanticIndex::from_graph(&products.graph);
index
.persist_local(&path)
.map_err(|error| error.to_string())?;
index
}
};
println!(
"{}",
semantic_index.search(&query.join(" "), limit).to_json()
);
Ok(())
}
fn index_path(input: &Path) -> Result<PathBuf, String> {
let root = if crate::project::is_project_input(input) && !input.is_dir() {
input.parent().unwrap_or_else(|| Path::new("."))
} else if crate::project::is_project_input(input) {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
let directory = root.join(".nox");
if directory
.symlink_metadata()
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(format!(
"refusing semantic index through symlinked {}",
directory.display()
));
}
Ok(directory.join("semantic-index.json"))
}
struct FileRepairCompiler {
path: PathBuf,
}
impl RepairCompiler for FileRepairCompiler {
fn compile(&mut self, source: &str) -> RepairCompilation {
let compilation = noxid_compiler_core::compile(&SourceFile::new(
SourceId(0),
&self.path,
source.to_string(),
));
RepairCompilation {
diagnostics: compilation.diagnostics,
program: compilation.program,
graph: compilation.graph,
}
}
}
fn repair_source(path: &Path, original: &str) -> (String, Vec<RepairOperation>, SafeRepairPlan) {
let mut compiler = FileRepairCompiler {
path: path.to_path_buf(),
};
let execution = execute_safe_repairs(original, &mut compiler);
let formatted = format_source(&execution.source);
let mut applied = execution.applied;
applied.extend(execution.refused);
(formatted.text, applied, execution.remaining)
}
fn merge_plans(plans: Vec<SafeRepairPlan>) -> SafeRepairPlan {
let mut operations = Vec::new();
let mut unresolved = Vec::new();
for plan in plans {
operations.extend(plan.operations);
unresolved.extend(plan.unresolved);
}
SafeRepairPlan {
operations,
unresolved,
}
}
fn repair_plan(input: &Path, products: &CompilerProducts) -> Result<SafeRepairPlan, String> {
if !crate::project::is_project_input(input) {
let source = fs::read_to_string(input)
.map_err(|error| format!("cannot read {}: {error}", input.display()))?;
return Ok(plan_safe_repairs_in(&source, &products.diagnostics));
}
let mut by_path: BTreeMap<Option<String>, Vec<Diagnostic>> = BTreeMap::new();
for diagnostic in &products.diagnostics {
by_path
.entry(diagnostic.path.clone())
.or_default()
.push(diagnostic.clone());
}
let mut plans = Vec::new();
for (path, diagnostics) in by_path {
let source = path
.as_deref()
.and_then(|path| fs::read_to_string(path).ok())
.unwrap_or_default();
plans.push(if source.is_empty() {
plan_safe_repairs(&diagnostics)
} else {
plan_safe_repairs_in(&source, &diagnostics)
});
}
Ok(merge_plans(plans))
}
fn run_repair(input: &Path, args: Vec<String>) -> Result<(), String> {
let safe = match args.as_slice() {
[] => false,
[flag] if flag == "--safe" => true,
_ => return Err("noxid repair accepts only --safe".into()),
};
crate::repair_transaction::recover_before_read(input)?;
let products = compiler_products(input)?;
let plan = repair_plan(input, &products)?;
if !safe {
println!("{}", plan.to_json());
return Ok(());
}
if crate::project::is_project_input(input) {
let (changed, applied) = apply_safe_project_repairs(input)?;
let products = compiler_products(input)?;
let remaining = repair_plan(input, &products)?;
println!(
"{{\"schemaVersion\":2,\"mode\":\"safe-local-transaction\",\"changedFiles\":{changed},\"appliedCount\":{},\"applied\":[{}],\"remainingPlan\":{}}}",
applied.len(),
operations_json(&applied),
remaining.to_json_with_mode("safe-local-transaction"),
);
return Ok(());
}
let original = fs::read_to_string(input)
.map_err(|error| format!("cannot read {}: {error}", input.display()))?;
let (repaired, applied, remaining) = repair_source(input, &original);
let validation =
noxid_compiler_core::compile(&SourceFile::new(SourceId(0), input, repaired.clone()));
if validation.has_errors() {
return Err(format!(
"safe repair was not applied because compiler validation failed: {}",
validation.diagnostics_json()
));
}
let changed = repaired != original;
if changed {
crate::repair_transaction::replace_file(input, &repaired)?;
}
println!(
"{{\"schemaVersion\":2,\"mode\":\"safe-local\",\"changed\":{changed},\"appliedCount\":{},\"applied\":[{}],\"remainingPlan\":{}}}",
applied.len(),
operations_json(&applied),
remaining.to_json_with_mode("safe-local"),
);
Ok(())
}
fn operations_json(operations: &[RepairOperation]) -> String {
operations
.iter()
.map(RepairOperation::to_json)
.collect::<Vec<_>>()
.join(",")
}
fn apply_safe_project_repairs(input: &Path) -> Result<(usize, Vec<RepairOperation>), String> {
let root = if input.is_file() {
input
.parent()
.ok_or_else(|| format!("{} has no project directory", input.display()))?
} else {
input
};
let source_root = root.join("src");
let mut paths = Vec::new();
collect_noxid_sources(&source_root, &mut paths)?;
paths.sort();
if paths.is_empty() {
return Err(format!(
"no .nox sources found under {}",
source_root.display()
));
}
let mut changes = Vec::new();
let mut applied = Vec::new();
for path in paths {
let original = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let (repaired, operations, _) = repair_source(&path, &original);
applied.extend(operations);
if repaired != original {
changes.push(FileRepair {
target: path,
original,
repaired,
});
}
}
if changes.is_empty() {
return Ok((0, applied));
}
let transaction = RepairTransaction::stage(root, &changes)?;
if let Err(error) = transaction.commit() {
let rollback = transaction.roll_back();
return Err(format!(
"safe project repair was rolled back: {error}{}",
rollback.map_or_else(String::new, |error| format!("; rollback failed: {error}"))
));
}
let validation = crate::project::query_diagnostics(input);
let validation_error = match validation {
Ok(diagnostics)
if !diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == noxid_source::Severity::Error) =>
{
None
}
Ok(diagnostics) => Some(format!(
"project still has {} diagnostic(s)",
diagnostics.len()
)),
Err(error) => Some(error),
};
if let Some(error) = validation_error {
let rollback = transaction.roll_back();
return Err(format!(
"safe project repair was rolled back because compiler validation failed: {error}{}",
rollback.map_or_else(String::new, |error| format!("; rollback failed: {error}"))
));
}
let changed = changes.len();
transaction.finish()?;
Ok((changed, applied))
}
fn collect_noxid_sources(directory: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(directory)
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
for entry in entries {
let entry = entry.map_err(|error| format!("cannot read directory entry: {error}"))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| format!("cannot inspect {}: {error}", path.display()))?;
if file_type.is_symlink() {
return Err(format!(
"safe project repair refuses source symlink {}",
path.display()
));
}
if file_type.is_dir() {
collect_noxid_sources(&path, output)?;
} else if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("nox")
{
output.push(path);
}
}
Ok(())
}
#[derive(Default)]
struct ScaffoldArgs {
route: Option<String>,
fields: Vec<(String, String)>,
capabilities: Vec<String>,
requirement: Option<String>,
parameters: Vec<(String, String)>,
output: Option<String>,
method: Option<String>,
path: Option<String>,
cache: Option<String>,
retry: Option<u32>,
variants: Vec<MachineVariant>,
initial: Option<String>,
initial_payload: Option<String>,
transitions: Vec<MachineTransition>,
write: bool,
}
fn run_scaffold(root: &Path, args: Vec<String>) -> Result<(), String> {
let kind = parse_feature_kind(
args.first()
.ok_or("noxid scaffold requires a feature kind")?,
)?;
let name = args
.get(1)
.ok_or("noxid scaffold requires a feature name")?
.clone();
let parsed = parse_scaffold_args(&args[2..])?;
let mut spec = FeatureSpec::component(name);
spec.kind = kind;
spec.route = parsed.route;
spec.fields = parsed.fields;
spec.capabilities = parsed.capabilities;
spec.requirement = parsed.requirement;
if kind == FeatureKind::Resource
&& (parsed.output.is_some() || parsed.method.is_some() || parsed.path.is_some())
{
spec.resource = Some(ResourceContract {
parameters: parsed.parameters,
output_type: parsed.output.unwrap_or_default(),
method: parsed.method.unwrap_or_default(),
path: parsed.path.unwrap_or_default(),
cache: parsed.cache,
retry: parsed.retry,
});
}
if kind == FeatureKind::StateMachine
&& (parsed.initial.is_some()
|| !parsed.variants.is_empty()
|| !parsed.transitions.is_empty())
{
spec.machine = Some(MachineContract {
variants: parsed.variants,
initial: parsed.initial.unwrap_or_default(),
initial_payload: parsed.initial_payload,
transitions: parsed.transitions,
});
}
let plan = plan_feature_scaffold(&spec);
if !plan.safe_to_apply {
return Err(format!("unsafe scaffold plan: {}", plan.to_json()));
}
validate_planned_sources(&plan)?;
if parsed.write {
apply_scaffold(root, &plan)?;
println!(
"{{\"schemaVersion\":1,\"written\":true,\"root\":\"{}\",\"plan\":{}}}",
json_escape(&root.display().to_string()),
plan.to_json()
);
} else {
println!("{}", plan.to_json());
}
Ok(())
}
fn parse_scaffold_args(args: &[String]) -> Result<ScaffoldArgs, String> {
let mut output = ScaffoldArgs::default();
let mut index = 0;
while index < args.len() {
let option = args[index].as_str();
match option {
"--route" => output.route = Some(value_after(args, &mut index, option)?),
"--field" => output
.fields
.push(parse_typed_field(&value_after(args, &mut index, option)?)?),
"--capability" => output
.capabilities
.push(value_after(args, &mut index, option)?),
"--requirement" => output.requirement = Some(value_after(args, &mut index, option)?),
"--parameter" => output
.parameters
.push(parse_typed_field(&value_after(args, &mut index, option)?)?),
"--output" => output.output = Some(value_after(args, &mut index, option)?),
"--method" => output.method = Some(value_after(args, &mut index, option)?),
"--path" => output.path = Some(value_after(args, &mut index, option)?),
"--cache" => output.cache = Some(value_after(args, &mut index, option)?),
"--retry" => {
output.retry = Some(
value_after(args, &mut index, option)?
.parse()
.map_err(|_| "--retry requires an unsigned integer")?,
)
}
"--variant" => output
.variants
.push(parse_variant(&value_after(args, &mut index, option)?)?),
"--initial" => output.initial = Some(value_after(args, &mut index, option)?),
"--initial-payload" => {
output.initial_payload = Some(value_after(args, &mut index, option)?)
}
"--transition" => output
.transitions
.push(parse_transition(&value_after(args, &mut index, option)?)?),
"--write" => output.write = true,
other => return Err(format!("unknown scaffold option `{other}`")),
}
index += 1;
}
Ok(output)
}
fn compiler_products(input: &Path) -> Result<CompilerProducts, String> {
if crate::project::is_project_input(input) {
return Ok(CompilerProducts {
graph: crate::project::query_graph(input)?,
programs: crate::project::query_programs(input)?,
diagnostics: crate::project::query_diagnostics(input)?,
});
}
if input.extension().and_then(|extension| extension.to_str()) != Some("nox") {
return Err(format!(
"expected a .nox source or Noxid project: {}",
input.display()
));
}
let text = fs::read_to_string(input)
.map_err(|error| format!("cannot read {}: {error}", input.display()))?;
let compilation = noxid_compiler_core::compile(&SourceFile::new(SourceId(0), input, text));
Ok(CompilerProducts {
graph: compilation.graph,
programs: vec![compilation.program],
diagnostics: compilation.diagnostics,
})
}
fn ensure_valid(products: &CompilerProducts) -> Result<(), String> {
if products
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == noxid_source::Severity::Error)
{
Err(format!(
"semantic command requires valid input; {} diagnostic(s)",
products.diagnostics.len()
))
} else {
Ok(())
}
}
fn validate_planned_sources(plan: &noxid_agent_planning::ScaffoldPlan) -> Result<(), String> {
for (index, file) in plan.files.iter().enumerate() {
let compilation = noxid_compiler_core::compile(&SourceFile::new(
SourceId(index as u32),
&file.path,
file.content.clone(),
));
if compilation.has_errors() {
return Err(format!(
"generated {} failed compiler validation: {}",
file.path,
compilation.diagnostics_json()
));
}
}
Ok(())
}
fn apply_scaffold(root: &Path, plan: &noxid_agent_planning::ScaffoldPlan) -> Result<(), String> {
let targets = plan
.files
.iter()
.map(|file| root.join(&file.path))
.collect::<Vec<_>>();
if let Some(existing) = targets.iter().find(|path| path.exists()) {
return Err(format!("refusing to overwrite {}", existing.display()));
}
for (file, target) in plan.files.iter().zip(targets) {
if file.overwrite {
return Err(format!("unsafe overwrite requested for {}", file.path));
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::write(&target, &file.content)
.map_err(|error| format!("cannot write {}: {error}", target.display()))?;
}
Ok(())
}
fn parse_catalog_kind(value: &str) -> Result<CatalogKind, String> {
match value {
"feature" => Ok(CatalogKind::Feature),
"operation" => Ok(CatalogKind::Operation),
"type" => Ok(CatalogKind::Type),
"diagnostic" => Ok(CatalogKind::Diagnostic),
_ => Err(format!("unknown description kind `{value}`")),
}
}
fn parse_projection(value: &str) -> Result<ManifestProjection, String> {
match value {
"compact" => Ok(ManifestProjection::Compact),
"agent" => Ok(ManifestProjection::Agent),
"full" => Ok(ManifestProjection::Full),
_ => Err(format!("unknown manifest projection `{value}`")),
}
}
fn parse_feature_kind(value: &str) -> Result<FeatureKind, String> {
match value {
"component" => Ok(FeatureKind::Component),
"route" => Ok(FeatureKind::Route),
"form" => Ok(FeatureKind::Form),
"list-page" => Ok(FeatureKind::ListPage),
"resource" => Ok(FeatureKind::Resource),
"state-machine" => Ok(FeatureKind::StateMachine),
_ => Err(format!("unknown scaffold kind `{value}`")),
}
}
fn parse_id(value: &str, label: &str) -> Result<SemanticId, String> {
SemanticId::parse(value).ok_or_else(|| format!("{label} requires a stable semantic ID"))
}
fn parse_typed_field(value: &str) -> Result<(String, String), String> {
let (name, ty) = value
.split_once(':')
.ok_or("typed fields require name:Type")?;
if name.is_empty() || ty.is_empty() {
return Err("typed fields require non-empty name:Type".into());
}
Ok((name.into(), ty.into()))
}
fn parse_variant(value: &str) -> Result<MachineVariant, String> {
let (name, payload_type) = value
.split_once(':')
.map_or((value, None), |(name, ty)| (name, Some(ty.to_string())));
if name.is_empty() {
return Err("--variant requires Name or Name:Type".into());
}
Ok(MachineVariant {
name: name.into(),
payload_type,
})
}
fn parse_transition(value: &str) -> Result<MachineTransition, String> {
let parts = value.splitn(4, ',').collect::<Vec<_>>();
if parts.len() < 3 || parts[..3].iter().any(|part| part.is_empty()) {
return Err("--transition requires from,to,event[,payload]".into());
}
Ok(MachineTransition {
from: parts[0].into(),
to: parts[1].into(),
event: parts[2].into(),
payload: parts.get(3).map(|value| (*value).to_string()),
})
}
fn value_after(args: &[String], index: &mut usize, option: &str) -> Result<String, String> {
*index += 1;
args.get(*index)
.cloned()
.ok_or_else(|| format!("{option} requires a value"))
}
fn parse_usize(value: &str, option: &str) -> Result<usize, String> {
value
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{option} requires a positive integer"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_project(label: &str, source: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("noxid-ai-cli-{label}-{nonce}"));
let routes = root.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
root.join("Noxid.toml"),
"[app]\ntitle = \"Repair Test\"\nroutes = \"src/routes\"\n",
)
.unwrap();
fs::write(routes.join("+page.nox"), source).unwrap();
root
}
fn compile_program(name: &str, constraint: &str) -> SemanticProgram {
let text = format!(
"component {name} {{ intent {{ purpose: \"Test {name}\" constraints: [\"{constraint}\"] }} view {{ <main></main> }} }}"
);
let compilation = noxid_compiler_core::compile(&SourceFile::new(
SourceId(0),
format!("{name}.nox"),
text,
));
assert!(
!compilation.has_errors(),
"{}",
compilation.diagnostics_json()
);
compilation.program
}
#[test]
fn parses_scaffold_contract_parts() {
assert_eq!(
parse_typed_field("id:Int").unwrap(),
("id".into(), "Int".into())
);
assert_eq!(
parse_variant("Ready:String").unwrap().payload_type,
Some("String".into())
);
let transition = parse_transition("Idle,Ready,resolve,\"done\"").unwrap();
assert_eq!(transition.event, "resolve");
assert_eq!(transition.payload, Some("\"done\"".into()));
}
#[test]
fn rejects_ambiguous_or_unbounded_options() {
assert!(parse_typed_field("value").is_err());
assert!(parse_transition("Idle,Ready").is_err());
assert!(parse_usize("0", "--max-bytes").is_err());
assert!(parse_projection("source").is_err());
}
#[test]
fn safe_project_repairs_are_atomic_and_roll_back_invalid_output() {
let valid_source = "component HomePage { view { <main><h1>Hello</h1></main> } }";
let valid = test_project("valid-repair", valid_source);
assert_eq!(apply_safe_project_repairs(&valid).unwrap().0, 1);
let formatted = fs::read_to_string(valid.join("src/routes/+page.nox")).unwrap();
assert_ne!(formatted, valid_source);
assert!(
crate::project::query_diagnostics(&valid)
.unwrap()
.iter()
.all(|diagnostic| diagnostic.severity != noxid_source::Severity::Error)
);
let invalid_source =
"component BrokenPage { state { count: Int = \"bad\" } view { <p>{count}</p> } }";
let invalid = test_project("rollback-repair", invalid_source);
let error = apply_safe_project_repairs(&invalid).unwrap_err();
assert!(error.contains("rolled back"), "{error}");
assert_eq!(
fs::read_to_string(invalid.join("src/routes/+page.nox")).unwrap(),
invalid_source
);
fs::remove_dir_all(valid).unwrap();
fs::remove_dir_all(invalid).unwrap();
}
#[test]
fn drift_adapter_detects_removed_intent_constraints() {
let before = test_project(
"drift-before",
"component Checkout { intent { purpose: \"Complete checkout\" constraints: [\"Never charge twice\", \"Require payment\"] } view { <main></main> } }",
);
let after = test_project(
"drift-after",
"component Checkout { intent { purpose: \"Complete checkout\" constraints: [\"Require payment\"] } view { <main></main> } }",
);
let report = intent_drift(&before, &after).unwrap();
assert!(report.has_errors());
assert!(
report
.findings
.iter()
.any(|finding| finding.code == "INTENT_CONSTRAINT_REMOVED")
);
fs::remove_dir_all(before).unwrap();
fs::remove_dir_all(after).unwrap();
}
#[test]
fn drift_comparison_is_stable_across_program_order() {
let alpha = compile_program("Alpha", "Keep alpha stable");
let beta = compile_program("Beta", "Keep beta stable");
let forward = merge_program_components(&[alpha.clone(), beta.clone()]);
let reverse = merge_program_components(&[beta, alpha]);
assert_eq!(
forward
.components
.iter()
.map(|component| component.id.clone())
.collect::<Vec<_>>(),
reverse
.components
.iter()
.map(|component| component.id.clone())
.collect::<Vec<_>>()
);
let report = check_intent_drift(&forward, &reverse);
assert!(!report.has_errors());
assert!(report.findings.is_empty());
}
}