Skip to content

Commit edc979e

Browse files
authored
feat: implement barzel init --force to overwrite existing .barzel.toml (#49)
* feat: implement barzel init --force to overwrite existing .barzel.toml - Add --force flag to 'barzel init' CLI command - Add force: bool field to StdioRequest for {"command":"init"} - run_init now returns InitOutcome (Created/Skipped/Overwritten) - Existing config is skipped by default; overwritten only when force=true - stdio init response includes config_status field with the outcome - README updated with --force example and stdio force field * fix: conditional init message and document config_status in README - init response message is now outcome-conditional: created -> 'project initialized' skipped -> 'config already exists' overwritten -> 'config overwritten' - README documents config_status table so agents know the three values - Test verifies message/status mapping is stable * refactor: extract init_status_and_message helper; test calls production code Test now calls the same init_status_and_message() used by handle_stdio instead of duplicating the match, so it guards the actual stdio contract.
1 parent bc8c05e commit edc979e

5 files changed

Lines changed: 140 additions & 24 deletions

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ Language matrix: Rust, TypeScript, Python, Go. `semgrep` runs on all languages.
2828
## Human CLI
2929

3030
```bash
31-
barzel init . # write .barzel.toml with defaults
31+
barzel init . # write .barzel.toml with defaults (skips if already exists)
32+
barzel init . --force # overwrite existing .barzel.toml
3233
barzel check # show tool availability and required installs
3334
barzel run # run all layers, human output
3435
barzel run --layer logic,hostile # run specific layers
@@ -57,6 +58,7 @@ Send a single JSON object to stdin; barzel writes newline-delimited JSON to stdo
5758

5859
```bash
5960
echo '{"command":"init","project_path":"."}' | barzel --stdio
61+
echo '{"command":"init","project_path":".","force":true}' | barzel --stdio
6062
echo '{"command":"check","project_path":"."}' | barzel --stdio
6163
echo '{"command":"run"}' | barzel --stdio
6264
echo '{"command":"run","layers":["logic"],"fail_fast":true}' | barzel --stdio
@@ -83,6 +85,17 @@ echo '{"command":"history","limit":10,"package_path":"crates/api"}' | barzel --s
8385
| `limit` | int | 20 | max history entries (cap 200, 0 returns empty) |
8486
| `package_path` | string || filter history by workspace member path |
8587
| `language` | string || filter history by language |
88+
| `force` | bool | false | overwrite existing `.barzel.toml` (for `init` command) |
89+
90+
### Init response `data` fields
91+
92+
The `init` success response always includes `config_status`, which agents must check to know whether the config was actually written:
93+
94+
| `config_status` | `message` | Meaning |
95+
|-----------------|-----------|---------|
96+
| `"created"` | `"project initialized"` | `.barzel.toml` was written for the first time |
97+
| `"skipped"` | `"config already exists"` | `.barzel.toml` already existed; no changes made |
98+
| `"overwritten"` | `"config overwritten"` | `.barzel.toml` was replaced because `force: true` |
8699

87100
### Response envelope
88101

src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ pub enum Commands {
2020
Init {
2121
/// Target directory (defaults to current directory)
2222
path: Option<PathBuf>,
23+
24+
/// Overwrite an existing .barzel.toml
25+
#[arg(long)]
26+
force: bool,
2327
},
2428

2529
/// Run the full verification suite (or specific layers)

src/init.rs

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ use crate::error::Result;
44
use owo_colors::OwoColorize;
55
use std::path::Path;
66

7-
pub fn run_init(target: Option<&Path>, stdio: bool) -> Result<()> {
7+
/// Result of running init, for structured callers (e.g. stdio response).
8+
#[derive(Debug, PartialEq)]
9+
pub enum InitOutcome {
10+
Created,
11+
Skipped,
12+
Overwritten,
13+
}
14+
15+
pub fn run_init(target: Option<&Path>, stdio: bool, force: bool) -> Result<InitOutcome> {
816
let target_path = target.unwrap_or_else(|| Path::new("."));
917
let info = detect_project(target_path)?;
1018

@@ -20,25 +28,32 @@ pub fn run_init(target: Option<&Path>, stdio: bool) -> Result<()> {
2028
let config = BarzelConfig::from_project_info(&info);
2129
let config_path = target_path.join(".barzel.toml");
2230

23-
if config_path.exists() {
31+
if config_path.exists() && !force {
2432
if !stdio {
2533
println!(
2634
"{} .barzel.toml already exists — skipping (use --force to overwrite)",
2735
"⚠".bright_yellow()
2836
);
2937
}
30-
return Ok(());
38+
return Ok(InitOutcome::Skipped);
3139
}
3240

41+
let outcome = if config_path.exists() { InitOutcome::Overwritten } else { InitOutcome::Created };
42+
3343
config.save(&config_path)?;
3444

3545
let barzel_dir = target_path.join(".barzel");
3646
std::fs::create_dir_all(&barzel_dir)?;
3747

3848
if !stdio {
49+
let action = match outcome {
50+
InitOutcome::Overwritten => "Overwrote",
51+
_ => "Created",
52+
};
3953
println!(
40-
"{} Created {}",
54+
"{} {} {}",
4155
"✓".bright_green(),
56+
action,
4257
".barzel.toml".bright_cyan()
4358
);
4459
println!(
@@ -52,7 +67,7 @@ pub fn run_init(target: Option<&Path>, stdio: bool) -> Result<()> {
5267
println!(" {} barzel run --layer logic", "→".bright_blue());
5368
}
5469

55-
Ok(())
70+
Ok(outcome)
5671
}
5772

5873
#[cfg(test)]
@@ -65,45 +80,58 @@ mod tests {
6580
fn init_creates_barzel_toml() {
6681
let dir = tempdir().unwrap();
6782
fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"test\"").unwrap();
68-
run_init(Some(dir.path()), true).unwrap();
83+
let outcome = run_init(Some(dir.path()), true, false).unwrap();
6984
assert!(dir.path().join(".barzel.toml").exists());
85+
assert_eq!(outcome, InitOutcome::Created);
7086
}
7187

7288
#[test]
7389
fn init_creates_barzel_directory() {
7490
let dir = tempdir().unwrap();
7591
fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"test\"").unwrap();
76-
run_init(Some(dir.path()), true).unwrap();
92+
run_init(Some(dir.path()), true, false).unwrap();
7793
assert!(dir.path().join(".barzel").exists());
7894
}
7995

8096
#[test]
81-
fn init_skips_if_toml_already_exists() {
97+
fn init_skips_if_toml_already_exists_without_force() {
8298
let dir = tempdir().unwrap();
8399
fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"test\"").unwrap();
84-
// First init
85-
run_init(Some(dir.path()), true).unwrap();
100+
run_init(Some(dir.path()), true, false).unwrap();
86101
// Overwrite .barzel.toml with sentinel content
87102
fs::write(dir.path().join(".barzel.toml"), b"# sentinel").unwrap();
88-
// Second init should skip (not overwrite)
89-
run_init(Some(dir.path()), true).unwrap();
103+
// Second init without --force should skip
104+
let outcome = run_init(Some(dir.path()), true, false).unwrap();
105+
let content = fs::read_to_string(dir.path().join(".barzel.toml")).unwrap();
106+
assert!(content.contains("sentinel"), "sentinel must be preserved when skipping");
107+
assert_eq!(outcome, InitOutcome::Skipped);
108+
}
109+
110+
#[test]
111+
fn init_force_overwrites_existing_toml() {
112+
let dir = tempdir().unwrap();
113+
fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"test\"").unwrap();
114+
run_init(Some(dir.path()), true, false).unwrap();
115+
// Write sentinel
116+
fs::write(dir.path().join(".barzel.toml"), b"# sentinel").unwrap();
117+
// Force overwrite
118+
let outcome = run_init(Some(dir.path()), true, true).unwrap();
90119
let content = fs::read_to_string(dir.path().join(".barzel.toml")).unwrap();
91-
assert!(content.contains("sentinel"));
120+
assert!(!content.contains("sentinel"), "sentinel must be gone after force overwrite");
121+
assert_eq!(outcome, InitOutcome::Overwritten);
92122
}
93123

94124
#[test]
95125
fn init_uses_current_dir_when_no_path() {
96-
// This just checks it doesn't panic/error when path is None
97-
// (it will use `.` which exists)
98-
let result = run_init(None, true);
126+
let result = run_init(None, true, false);
99127
assert!(result.is_ok());
100128
}
101129

102130
#[test]
103131
fn init_works_for_typescript_project() {
104132
let dir = tempdir().unwrap();
105133
fs::write(dir.path().join("package.json"), br#"{"name":"my-app"}"#).unwrap();
106-
run_init(Some(dir.path()), true).unwrap();
134+
run_init(Some(dir.path()), true, false).unwrap();
107135
assert!(dir.path().join(".barzel.toml").exists());
108136
let content = fs::read_to_string(dir.path().join(".barzel.toml")).unwrap();
109137
assert!(content.contains("typescript") || content.contains("my-app"));
@@ -113,7 +141,7 @@ mod tests {
113141
fn init_toml_contains_project_name() {
114142
let dir = tempdir().unwrap();
115143
fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"my-crate\"").unwrap();
116-
run_init(Some(dir.path()), true).unwrap();
144+
run_init(Some(dir.path()), true, false).unwrap();
117145
let content = fs::read_to_string(dir.path().join(".barzel.toml")).unwrap();
118146
assert!(content.contains("my-crate"));
119147
}

src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,8 @@ fn main() -> ExitCode {
425425
};
426426

427427
match command {
428-
Commands::Init { path } => match init::run_init(path.as_deref(), false) {
429-
Ok(()) => ExitCode::SUCCESS,
428+
Commands::Init { path, force } => match init::run_init(path.as_deref(), false, force) {
429+
Ok(_) => ExitCode::SUCCESS,
430430
Err(e) => {
431431
eprintln!("{} {}", "Error:".bright_red(), e);
432432
ExitCode::from(1)

src/stdio.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ pub(crate) struct StdioRequest {
3838
pub(crate) package_path: Option<String>,
3939
#[serde(default)]
4040
pub(crate) language: Option<String>,
41+
/// Overwrite existing .barzel.toml when running the `init` command.
42+
#[serde(default)]
43+
pub(crate) force: bool,
4144
}
4245

4346
#[derive(serde::Serialize)]
@@ -306,6 +309,15 @@ pub(crate) fn build_stdio_report_payload(
306309
Ok(serde_json::json!({ "report": report_json }))
307310
}
308311

312+
fn init_status_and_message(outcome: &crate::init::InitOutcome) -> (&'static str, &'static str) {
313+
use crate::init::InitOutcome;
314+
match outcome {
315+
InitOutcome::Created => ("created", "project initialized"),
316+
InitOutcome::Skipped => ("skipped", "config already exists"),
317+
InitOutcome::Overwritten => ("overwritten", "config overwritten"),
318+
}
319+
}
320+
309321
// ── Command dispatcher ────────────────────────────────────────────────────────
310322

311323
pub(crate) fn handle_stdio() -> ExitCode {
@@ -329,15 +341,17 @@ pub(crate) fn handle_stdio() -> ExitCode {
329341
"init" => {
330342
let path = req.project_path.as_deref().map(Path::new);
331343
let target = path.unwrap_or_else(|| Path::new("."));
332-
match crate::init::run_init(Some(target), true) {
333-
Ok(()) => {
344+
match crate::init::run_init(Some(target), true, req.force) {
345+
Ok(outcome) => {
346+
let (config_status, message) = init_status_and_message(&outcome);
334347
let project = crate::detect::detect_project(target).ok();
335348
let resp = create_response(
336349
"success",
337350
request_id,
338351
Some(serde_json::json!({
339-
"message": "project initialized",
352+
"message": message,
340353
"config_file": ".barzel.toml",
354+
"config_status": config_status,
341355
"language": project.as_ref().map(|p| p.language.to_string()),
342356
"frameworks": project.as_ref().map(|p| serde_json::json!({
343357
"is_nextjs": p.frameworks.is_nextjs,
@@ -1170,6 +1184,63 @@ mod tests {
11701184
assert_eq!(entries[0]["language"].as_str(), Some("rust"));
11711185
}
11721186

1187+
// ── stdio init / force flag ───────────────────────────────────────────────
1188+
1189+
#[test]
1190+
fn stdio_request_parses_force_true() {
1191+
let json = r#"{"command":"init","force":true}"#;
1192+
let req: StdioRequest = serde_json::from_str(json).unwrap();
1193+
assert!(req.force, "force:true must be parsed from request");
1194+
}
1195+
1196+
#[test]
1197+
fn stdio_request_force_defaults_to_false() {
1198+
let json = r#"{"command":"init"}"#;
1199+
let req: StdioRequest = serde_json::from_str(json).unwrap();
1200+
assert!(!req.force, "force must default to false when absent");
1201+
}
1202+
1203+
#[test]
1204+
fn init_build_run_data_config_status_skipped() {
1205+
use crate::init::{InitOutcome, run_init};
1206+
let dir = tempfile::tempdir().unwrap();
1207+
std::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"x\"").unwrap();
1208+
// First init creates the file
1209+
run_init(Some(dir.path()), true, false).unwrap();
1210+
// Second init without force returns Skipped
1211+
let outcome = run_init(Some(dir.path()), true, false).unwrap();
1212+
assert_eq!(outcome, InitOutcome::Skipped);
1213+
}
1214+
1215+
#[test]
1216+
fn init_force_returns_overwritten_outcome() {
1217+
use crate::init::{InitOutcome, run_init};
1218+
let dir = tempfile::tempdir().unwrap();
1219+
std::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"x\"").unwrap();
1220+
run_init(Some(dir.path()), true, false).unwrap();
1221+
std::fs::write(dir.path().join(".barzel.toml"), b"# sentinel").unwrap();
1222+
let outcome = run_init(Some(dir.path()), true, true).unwrap();
1223+
assert_eq!(outcome, InitOutcome::Overwritten);
1224+
let content = std::fs::read_to_string(dir.path().join(".barzel.toml")).unwrap();
1225+
assert!(!content.contains("sentinel"), "force must overwrite sentinel content");
1226+
}
1227+
1228+
#[test]
1229+
fn init_config_status_and_message_mapping() {
1230+
use crate::init::InitOutcome;
1231+
// Call the production helper used by handle_stdio so the test guards the actual contract.
1232+
let cases = [
1233+
(InitOutcome::Created, "created", "project initialized"),
1234+
(InitOutcome::Skipped, "skipped", "config already exists"),
1235+
(InitOutcome::Overwritten, "overwritten", "config overwritten"),
1236+
];
1237+
for (outcome, expected_status, expected_msg) in cases {
1238+
let (status, msg) = init_status_and_message(&outcome);
1239+
assert_eq!(status, expected_status, "config_status must be '{expected_status}'");
1240+
assert_eq!(msg, expected_msg, "message must be '{expected_msg}'");
1241+
}
1242+
}
1243+
11731244
// ── tool registry / check payload ─────────────────────────────────────────
11741245

11751246
#[test]

0 commit comments

Comments
 (0)