crates/cli/src/cmd/init.rs 3.8 KiB 116 lines Raw
1 #![forbid(unsafe_code)]2 //! f0rge init: bootstrap .forge skeleton per spec/schema.md. Idempotent.3 use crate::CliError;4 5 pub fn help() -> &'static str {6     "f0rge init: bootstrap\nusage: f0rge init [dir]\ncreates .forge/ skeleton; idempotent\ndocs: spec/schema.md §Init\ncontract: f0rge --skill\n"7 }8 9 /// Bootstrap skeleton. Nothing else. Existing .forge kept.10 pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {11     for a in args {12         if a == "-h" || a == "--help" {13             crate::output::out(help());14             return Ok(());15         }16         if a.starts_with('-') {17             return Err(CliError::Usage(format!("unknown flag: {a}")));18         }19     }20     if args.len() > 1 {21         return Err(CliError::Usage("init [dir]".to_string()));22     }23     let target: std::path::PathBuf = if args.is_empty() {24         std::env::current_dir().map_err(|_| CliError::Io("cwd: io error".to_string()))?25     } else {26         std::path::PathBuf::from(&args[0])27     };28     init_at(&target).map_err(|e| CliError::Io(e.to_string()))?;29     Ok(())30 }31 32 fn io_err(file: &str) -> std::io::Error {33     std::io::Error::other(file.to_string())34 }35 36 fn init_at(target: &std::path::Path) -> Result<(), std::io::Error> {37     let forge = target.join(".forge");38     for d in [39         "issues",40         "prs",41         "tasks",42         "wiki/docs",43         "wiki/research",44         "wiki/experiments",45         "ci/steps",46         "releases/build",47         "merges",48     ] {49         std::fs::create_dir_all(forge.join(d))?;50     }51     // files kept if present, created if missing.52     let labels = forge.join("labels.md");53     if !labels.exists() {54         write_new(&labels, b"[]\n")?;55     }56     let ms = forge.join("milestones.md");57     if !ms.exists() {58         write_new(&ms, b"[]\n")?;59     }60     let env = forge.join("ci/ci.env");61     if !env.exists() {62         write_new(&env, b"runner: local\n")?;63     }64     // starter spec: only if absent, never clobber (single source = embedded SPEC_TEXT).65     let spec_dir = target.join("spec");66     std::fs::create_dir_all(&spec_dir)?;67     let schema = spec_dir.join("schema.md");68     if !schema.exists() {69         write_new(&schema, crate::cmd::spec::SPEC_TEXT.as_bytes())?;70     }71     ensure_gitignore(target)?;72     Ok(())73 }74 75 /// Create-only write (no clobber), atomic via tmp+rename.76 fn write_new(path: &std::path::Path, bytes: &[u8]) -> Result<(), std::io::Error> {77     use std::io::Write;78     let name = path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or("?".into());79     if path.exists() {80         return Ok(());81     }82     let tmp = path.with_extension("tmp");83     let mut f = std::fs::File::create(&tmp).map_err(|_| io_err(&name))?;84     f.write_all(bytes).map_err(|_| io_err(&name))?;85     f.sync_all().map_err(|_| io_err(&name))?;86     drop(f);87     std::fs::rename(&tmp, path).map_err(|_| io_err(&name))?;88     Ok(())89 }90 91 /// Append gitignore lines once each.92 fn ensure_gitignore(target: &std::path::Path) -> Result<(), std::io::Error> {93     use std::io::Write;94     let gi = target.join(".gitignore");95     let want = [".forge/ci/runs/", ".forge/releases/out/", "*.tmp"];96     let cur = std::fs::read_to_string(&gi).unwrap_or_default();97     let mut missing: Vec<&str> = want.iter().copied().filter(|w| !cur.lines().any(|l| l.trim() == *w)).collect();98     if missing.is_empty() {99         return Ok(());100     }101     let mut body = cur;102     if !body.is_empty() && !body.ends_with('\n') {103         body.push('\n');104     }105     for w in missing.drain(..) {106         body.push_str(w);107         body.push('\n');108     }109     let tmp = gi.with_extension("tmp");110     let mut f = std::fs::File::create(&tmp).map_err(|_| io_err(".gitignore"))?;111     f.write_all(body.as_bytes()).map_err(|_| io_err(".gitignore"))?;112     f.sync_all().map_err(|_| io_err(".gitignore"))?;113     drop(f);114     std::fs::rename(&tmp, &gi).map_err(|_| io_err(".gitignore"))?;115     Ok(())116 }