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 }