use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::Serialize;
use crate::output::CommandReport;
use crate::paths::write;
struct StarterFile {
relative_path: &'static str,
contents: &'static str,
}
const STARTER_FILES: &[StarterFile] = &[StarterFile {
relative_path: "AGENTS.md",
contents: include_str!("../../templates/level-1/AGENTS.md"),
}];
#[derive(Serialize)]
pub struct ScaffoldReport {
command: &'static str,
ok: bool,
action: &'static str,
path: String,
files: Vec<String>,
conflicts: Vec<String>,
message: String,
next_steps: Vec<String>,
}
impl CommandReport for ScaffoldReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn render_text(&self) {
println!("{}", self.message);
if self.ok {
for file in &self.files {
println!("Wrote {file}");
}
if self.next_steps.is_empty() {
return;
}
println!();
println!("Next steps:");
for (index, step) in self.next_steps.iter().enumerate() {
println!("{}. {step}", index + 1);
}
return;
}
for conflict in &self.conflicts {
println!("Conflict: {conflict}");
}
}
}
pub fn run(target_dir: &Path, force: bool) -> Result<ScaffoldReport> {
validate_target(target_dir)?;
let conflicts = STARTER_FILES
.iter()
.map(|file| target_dir.join(file.relative_path))
.filter(|path| path.exists())
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
if !force && !conflicts.is_empty() {
return Ok(ScaffoldReport {
command: "scaffold",
ok: false,
action: "refused",
path: target_dir.display().to_string(),
files: Vec::new(),
conflicts,
message: "Starter surfaces already exist; refusing to overwrite. Use `ccd scaffold --force` to replace them.".to_owned(),
next_steps: Vec::new(),
});
}
let mut files = Vec::new();
for file in STARTER_FILES {
let destination = target_dir.join(file.relative_path);
if destination.is_dir() {
bail!(
"starter target is a directory, refusing to overwrite: {}",
destination.display()
);
}
let write_result = if force {
write::replace_text(&destination, file.contents, None)
} else {
write::create_text(&destination, file.contents, None)
};
write_result.with_context(|| format!("failed to write {}", destination.display()))?;
files.push(destination.display().to_string());
}
let action = if conflicts.is_empty() {
"created"
} else {
"updated"
};
let message = match action {
"created" => "Scaffolded project-truth starter surfaces.",
"updated" => "Overwrote project-truth starter surfaces.",
_ => unreachable!(),
};
let next_steps = vec![
"Replace the starter placeholders in `AGENTS.md`.".to_owned(),
"Run `ccd attach --path .` if this repo is not linked into CCD state yet.".to_owned(),
"Run `ccd sync --path .` after curating `AGENTS.md` if you use generated policy mirrors."
.to_owned(),
];
Ok(ScaffoldReport {
command: "scaffold",
ok: true,
action,
path: target_dir.display().to_string(),
files,
conflicts,
message: message.to_owned(),
next_steps,
})
}
fn validate_target(target_dir: &Path) -> Result<()> {
if !target_dir.exists() {
bail!("target directory does not exist: {}", target_dir.display());
}
if !target_dir.is_dir() {
bail!("target path is not a directory: {}", target_dir.display());
}
Ok(())
}