Skip to content

Commit 46815a4

Browse files
committed
feat(convert): --force to overwrite existing contracts; commas in messages
Add `apic convert --postman --force`; the default still errors on an existing file and now suggests --force. Sweep user-facing messages to use commas instead of semicolons and em-dashes.
1 parent 1f59663 commit 46815a4

9 files changed

Lines changed: 67 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `apic convert --postman` gains a `--force` flag to overwrite contracts that
12+
already exist. The default still refuses (erroring on an existing file), and
13+
the error now points to `--force`.
14+
1015
### Changed
1116
- **Contract format (breaking).** A request body is now the raw JSON payload
1217
written directly under `request` (no `example` wrapper), and a response body is
1318
written under the response's `schema` key (renamed from `example`). Contracts
1419
using the previous `{ "example": ... }` body shape must be updated.
20+
- User-facing messages use commas instead of semicolons and em-dashes.
1521

1622
## [0.4.0] - 2026-07-06
1723

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,13 @@ v1.0.0, v2.0.0, and v2.1.0 (auto-detected).
364364
- `--destination <dir>`, where to write the contracts, relative to the working
365365
directory (created if missing). **Optional**, defaults to the working
366366
directory itself. The path is confined to the working directory (`..`/absolute
367-
escapes are rejected) and existing files are never overwritten.
367+
escapes are rejected). An existing contract is left untouched (the import
368+
errors) unless `--force` is passed.
369+
- `--force`, overwrite contracts that already exist instead of erroring.
368370

369371
Each Postman folder becomes a directory and each request becomes
370372
`folder/request_name.json`. Only the fields apic models are imported (method,
371-
URL, headers, request/response bodies); Postman-specific data (auth blocks,
373+
URL, headers, request/response bodies), Postman-specific data (auth blocks,
372374
scripts, events, variables) is ignored. A request whose HTTP method apic does
373375
not model (anything other than `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/
374376
`OPTIONS`) is imported as `GET` with a warning, so nothing is downgraded
@@ -378,6 +380,7 @@ silently.
378380
apic init # an apic project is required
379381
apic convert --postman MyAPI.postman.json # writes into the working directory
380382
apic convert --postman MyAPI.postman.json --destination imported
383+
apic convert --postman MyAPI.postman.json --force # overwrite existing contracts
381384
```
382385

383386
```text

apic-core/src/convert.rs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ fn method_warning(method: &str, name: &str) -> Option<String> {
178178
None
179179
} else {
180180
Some(format!(
181-
"request {name:?} uses method {upper}, unsupported by apic imported as GET"
181+
"request {name:?} uses method {upper}, unsupported by apic, imported as GET"
182182
))
183183
}
184184
}
@@ -456,15 +456,20 @@ fn map(collection: &PostmanCollection) -> Vec<MappedContract> {
456456

457457
/// Write mapped contracts under `dest_base`. Each contract's `rel_path` is
458458
/// confined under `dest_base` (rejecting `..` escapes), its parent directories
459-
/// are created, and the pretty-printed JSON is written. Existing files are not
460-
/// overwritten. Returns the number of files written.
461-
fn write_contracts(dest_base: &Path, mapped: &[MappedContract]) -> Result<usize, String> {
459+
/// are created, and the pretty-printed JSON is written. An existing file is left
460+
/// untouched (erroring) unless `overwrite` is set. Returns the number of files
461+
/// written.
462+
fn write_contracts(
463+
dest_base: &Path,
464+
mapped: &[MappedContract],
465+
overwrite: bool,
466+
) -> Result<usize, String> {
462467
let mut written = 0usize;
463468
for item in mapped {
464469
let path = confine_to_dir(dest_base, &item.rel_path)?;
465-
if path.exists() {
470+
if !overwrite && path.exists() {
466471
return Err(format!(
467-
"{} already exists; refusing to overwrite",
472+
"{} already exists, pass --force to overwrite",
468473
path.display()
469474
));
470475
}
@@ -490,13 +495,17 @@ pub struct ConvertOutcome {
490495

491496
/// Parses the collection at `collection_path`, maps it, writes contracts under
492497
/// `dest_base`, and returns what happened. Does not print — the caller reports.
493-
pub fn run(collection_path: &Path, dest_base: &Path) -> Result<ConvertOutcome, String> {
498+
pub fn run(
499+
collection_path: &Path,
500+
dest_base: &Path,
501+
overwrite: bool,
502+
) -> Result<ConvertOutcome, String> {
494503
let collection = converter::from_path(collection_path)?;
495504
let mapped = map(&collection);
496505
if mapped.is_empty() {
497506
return Err("collection contained no convertible requests".to_string());
498507
}
499-
let written = write_contracts(dest_base, &mapped)?;
508+
let written = write_contracts(dest_base, &mapped, overwrite)?;
500509
let warnings: Vec<String> = mapped.iter().filter_map(|m| m.warning.clone()).collect();
501510
Ok(ConvertOutcome {
502511
written,
@@ -821,13 +830,18 @@ mod tests {
821830
warning: None,
822831
}];
823832

824-
let n = write_contracts(&base, &mapped).unwrap();
833+
let n = write_contracts(&base, &mapped, false).unwrap();
825834
assert_eq!(n, 1);
826835
assert!(base.join("users").join("get_user.json").is_file());
827836

828-
// Second write to the same path is refused.
829-
let err = write_contracts(&base, &mapped).unwrap_err();
837+
// Second write to the same path is refused, and points to --force.
838+
let err = write_contracts(&base, &mapped, false).unwrap_err();
830839
assert!(err.contains("already exists"));
840+
assert!(err.contains("--force"));
841+
842+
// With overwrite, the same path is rewritten instead of erroring.
843+
let n = write_contracts(&base, &mapped, true).unwrap();
844+
assert_eq!(n, 1);
831845

832846
std::fs::remove_dir_all(&base).unwrap();
833847
}

apic-core/src/template.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ fn resolve_at(apic_dir: &Path) -> Result<(String, Vec<String>), String> {
154154
if let Err(err) = seed_if_missing(apic_dir) {
155155
return Ok((
156156
DEFAULT.to_string(),
157-
vec![format!("{err}; using the built-in template")],
157+
vec![format!("{err}, using the built-in template")],
158158
));
159159
}
160160
resolve_contract_from(&resolve_path(apic_dir))
@@ -173,7 +173,7 @@ pub fn resolve_contract_from(path: &Path) -> Result<(String, Vec<String>), Strin
173173
return Ok((
174174
DEFAULT.to_string(),
175175
vec![format!(
176-
"failed to read {}: {err}; using the built-in template",
176+
"failed to read {}: {err}, using the built-in template",
177177
path.display()
178178
)],
179179
));

apic-gui/src/desktop.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ pub fn install_desktop_entry() -> Result<String, String> {
117117
/// Non-Linux: launcher integration is handled by platform package managers.
118118
#[cfg(not(target_os = "linux"))]
119119
pub fn install_desktop_entry() -> Result<String, String> {
120-
Err("--desktop-entry is Linux-only. On macOS install via Homebrew (or use the .app from Releases); on Windows use winget (or the .exe from Releases).".to_string())
120+
Err("--desktop-entry is Linux-only. On macOS install via Homebrew (or use the .app from Releases), on Windows use winget (or the .exe from Releases).".to_string())
121121
}
122122

123123
#[cfg(all(test, target_os = "linux"))]

apic-gui/src/main.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,9 @@ impl App {
552552
self.status = "no project to import into".into();
553553
return;
554554
};
555-
match apic_core::convert::run(&src, &root) {
555+
// The GUI import never overwrites; existing contracts must be removed
556+
// first (or edited), matching the CLI default.
557+
match apic_core::convert::run(&src, &root, false) {
556558
Ok(out) => {
557559
self.reload_project();
558560
let warn = if out.warnings.is_empty() {
@@ -706,7 +708,7 @@ impl App {
706708

707709
if !is_folder {
708710
if dest.exists() {
709-
self.status = format!("{rel} already exists; not overwriting");
711+
self.status = format!("{rel} already exists, not overwriting");
710712
return;
711713
}
712714
// Seed from the chosen template (merged onto the built-in default),
@@ -775,7 +777,7 @@ impl App {
775777
}
776778
ui.add_space(SPACE_EXTRA_SMALL);
777779
ui.label(
778-
RichText::new("end with .json for a contract (auth/logout.json); a bare name makes a folder")
780+
RichText::new("end with .json for a contract (auth/logout.json), a bare name makes a folder")
779781
.color(DIM)
780782
.size(10.0),
781783
);
@@ -1261,7 +1263,7 @@ impl App {
12611263
ui.add_space(SPACE_SMALL);
12621264
if rep.error.is_empty() {
12631265
ui.label(
1264-
RichText::new("Valid opening editor…")
1266+
RichText::new("Valid, opening editor…")
12651267
.color(GREEN)
12661268
.strong(),
12671269
);
@@ -1578,7 +1580,7 @@ impl TreeNode {
15781580
ui.horizontal(|ui| {
15791581
if *invalid {
15801582
ui.label(RichText::new("●").color(RED))
1581-
.on_hover_text("Invalid contract click to repair");
1583+
.on_hover_text("Invalid contract, click to repair");
15821584
}
15831585
ui.label(RichText::new(method).color(method_color(method)).size(11.0));
15841586
// Reserve the delete button on the right, then let the file name

src/cli.rs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ enum Commands {
215215
/// one JSON contract per request, mirroring the collection's folder nesting.
216216
/// Files are written under `--destination`, resolved within the configured
217217
/// working directory; when omitted, the working directory itself is used.
218-
/// `..` escapes and absolute paths elsewhere are rejected, and existing
219-
/// files are never overwritten. Requires an initialized apic project
220-
/// (`apic init`).
218+
/// `..` escapes and absolute paths elsewhere are rejected. An existing
219+
/// contract is left untouched unless `--force` is passed. Requires an
220+
/// initialized apic project (`apic init`).
221221
Convert {
222222
/// Path to the Postman collection JSON file to import.
223223
#[arg(long, value_name = "FILE")]
@@ -228,6 +228,10 @@ enum Commands {
228228
/// directory from `.apic/config.toml`.
229229
#[arg(long, value_name = "DIR")]
230230
destination: Option<String>,
231+
232+
/// Overwrite contracts that already exist instead of erroring.
233+
#[arg(long)]
234+
force: bool,
231235
},
232236
}
233237

@@ -260,7 +264,7 @@ fn init_cmd(working_dir: Option<&str>) -> Result<(), String> {
260264
println!("Successfully initialized");
261265
}
262266
InitOutcome::TemplateSeeded => {
263-
println!("Already initialized; created the missing template")
267+
println!("Already initialized, created the missing template")
264268
}
265269
}
266270
Ok(())
@@ -467,9 +471,9 @@ fn select_create_template(
467471
fn no_template_error(name: &str, templates: &[PathBuf], root: &Path) -> String {
468472
let mut msg = format!("no template matching '{}'", sanitize(name));
469473
if templates.is_empty() {
470-
msg.push_str("; no templates in .apic/template/");
474+
msg.push_str(", no templates in .apic/template/");
471475
} else {
472-
msg.push_str("; available:\n");
476+
msg.push_str(", available:\n");
473477
for t in templates {
474478
msg.push_str(&format!(" {}\n", rel_display(t, root)));
475479
}
@@ -772,7 +776,7 @@ fn validate_cmd(template: bool, find: Option<&str>) -> Result<(), String> {
772776
if issues.is_empty() {
773777
Ok(())
774778
} else {
775-
Err(issues.join("; "))
779+
Err(issues.join(", "))
776780
}
777781
});
778782

@@ -804,13 +808,13 @@ fn validate_template_cmd() -> Result<(), String> {
804808
let apic_dir = match apic_core::config::find_apic_dir() {
805809
Some(dir) => dir,
806810
None => {
807-
println!("No project template found; create will use the built-in template");
811+
println!("No project template found, create will use the built-in template");
808812
return Ok(());
809813
}
810814
};
811815
let templates = apic_core::template::list_templates(&apic_dir);
812816
if templates.is_empty() {
813-
println!("No project template found; create will use the built-in template");
817+
println!("No project template found, create will use the built-in template");
814818
return Ok(());
815819
}
816820

@@ -1108,13 +1112,13 @@ fn remove_template_cmd(name: &str) -> Result<(), String> {
11081112

11091113
/// Handles `apic convert`: resolve the destination under the working directory,
11101114
/// then parse the Postman collection and write contracts.
1111-
fn convert_cmd(postman: &Path, destination: Option<&str>) -> Result<(), String> {
1115+
fn convert_cmd(postman: &Path, destination: Option<&str>, force: bool) -> Result<(), String> {
11121116
let root = read_config_file().and_then(|conf| conf.get_root_dir())?;
11131117
let dest_base = match destination {
11141118
Some(dir) => confine_to_dir(&root, Path::new(dir))?,
11151119
None => root,
11161120
};
1117-
let outcome = apic_core::convert::run(postman, &dest_base)?;
1121+
let outcome = apic_core::convert::run(postman, &dest_base, force)?;
11181122
for warning in &outcome.warnings {
11191123
eprintln!("warning: {warning}");
11201124
}
@@ -1294,7 +1298,8 @@ pub(crate) fn run() {
12941298
Commands::Convert {
12951299
postman,
12961300
destination,
1297-
} => convert_cmd(&postman, destination.as_deref()),
1301+
force,
1302+
} => convert_cmd(&postman, destination.as_deref(), force),
12981303
};
12991304

13001305
if let Err(err) = result {

src/render.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ impl Printer {
133133
b'4' | b'5' => code.red().bold(),
134134
_ => code.yellow().bold(),
135135
};
136-
println!(" {} {code} {description}", "RESPONSE".bold());
136+
println!(" {} {code}, {description}", "RESPONSE".bold());
137137
} else {
138-
println!(" RESPONSE {code} {description}");
138+
println!(" RESPONSE {code}, {description}");
139139
}
140140
}
141141

src/tui/draw.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -808,15 +808,15 @@ fn draw_help(frame: &mut Frame, area: Rect) {
808808
"Move between cells · switch response tabs",
809809
]),
810810
Row::new(vec!["Enter", "Edit cell · open example"]),
811-
Row::new(vec!["i", "Insert edit the focused text cell"]),
811+
Row::new(vec!["i", "Insert, edit the focused text cell"]),
812812
Row::new(vec!["Esc", "Back · cancel"]),
813813
Row::new(vec![
814814
"a",
815815
"Add row · REQUEST writes JSON · RESPONSE new-response form",
816816
]),
817817
Row::new(vec![
818818
"e",
819-
"Edit response tab: status/title · body: JSON example",
819+
"Edit, response tab: status/title · body: JSON example",
820820
]),
821821
Row::new(vec!["d", "Delete the selected row"]),
822822
Row::new(vec!["Ctrl-S", "Save"]),

0 commit comments

Comments
 (0)