use crate::config::{Config, InitOutcome, read_config_file};
use crate::file::{confine_to_dir, read_file, to_slash};
use crate::fuzzy::{fuzzy_find, fuzzy_match_path};
use crate::json::{json_get, scan_json_file, validate as validate_contract};
use crate::picker;
use crate::render::{render, sanitize};
use crate::tree;
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Parser)]
#[command(name = "apic")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(author = "rizukirr")]
#[command(about = "Git-able API contracts — per-endpoint JSON files in your repo")]
#[command(
long_about = "apic stores API contracts as plain per-endpoint JSON files in your \
repository, so they are versioned, diffable, and reviewable alongside code.\n\n\
Typical flow: `apic init` to set up a project, `apic config --set-dir <dir>` to point at your \
contracts folder, then `create`, `open`, `read`, and `validate` to work with contracts."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Config {
#[arg(long, value_name = "DIR")]
set_dir: Option<String>,
},
Init {
#[arg(long, value_name = "DIR")]
set_dir: Option<String>,
},
List {
#[arg(long, value_name = "QUERY")]
filter: Option<String>,
#[arg(long, value_name = "BOOL", default_value_t = false, action = clap::ArgAction::Set)]
absolute: bool,
},
Read {
#[arg(long, short = 'f', value_name = "QUERY")]
find: String,
#[arg(long, short = 's', value_name = "CODE")]
status: Option<u16>,
#[arg(long, short = 'e')]
example: bool,
},
Create {
#[arg(long, short = 'f', value_name = "FILENAME")]
filename: Option<String>,
#[arg(long, short = 'e', value_name = "EDITOR")]
editor: Option<String>,
},
Validate {
#[arg(long, short = 'f', value_name = "QUERY", conflicts_with = "template")]
find: Option<String>,
#[arg(long, conflicts_with = "find")]
template: bool,
},
Open {
#[arg(
long,
short = 'f',
value_name = "QUERY",
required_unless_present = "template",
conflicts_with = "template"
)]
find: Option<String>,
#[arg(long, short = 'e', value_name = "EDITOR")]
editor: Option<String>,
#[arg(long)]
template: bool,
},
Remove {
#[arg(long, short = 'f', value_name = "QUERY")]
find: String,
},
Convert {
#[arg(long, value_name = "FILE")]
postman: PathBuf,
#[arg(long, value_name = "DIR")]
destination: Option<String>,
},
}
pub fn update_working_dir(working_dir: Option<&str>) -> Result<(), String> {
match working_dir {
Some(dir) => {
read_config_file().and_then(|mut conf| conf.update_root_dir(dir))?;
println!("Successfully updated");
Ok(())
}
None => Ok(()),
}
}
pub fn init_cmd(working_dir: Option<&str>) -> Result<(), String> {
match Config::init(working_dir)? {
InitOutcome::Initialized => println!("Successfully initialized"),
InitOutcome::TemplateSeeded => {
println!("Already initialized; created the missing template")
}
}
Ok(())
}
pub fn list(is_absolute: bool) -> Option<Vec<PathBuf>> {
let root = match read_config_file().and_then(|conf| conf.get_root_dir()) {
Ok(root) => root,
Err(err) => {
eprintln!("{}", err);
std::process::exit(1);
}
};
scan_json_file(&root, is_absolute)
}
#[derive(Debug, PartialEq)]
enum Resolution {
One(PathBuf),
Many(Vec<PathBuf>),
None,
}
fn classify(filename: &str, root: &Path, files: &[PathBuf]) -> Resolution {
let candidates = [
PathBuf::from(filename),
PathBuf::from(format!("{filename}.json")),
];
for candidate in candidates {
if let Ok(path) = confine_to_dir(root, &candidate)
&& path.is_file()
{
return Resolution::One(path);
}
}
if !filename.contains('/') && !filename.contains('\\') {
let target = if filename.ends_with(".json") {
filename.to_string()
} else {
format!("{filename}.json")
};
let matches: Vec<PathBuf> = files
.iter()
.filter(|f| f.file_name().is_some_and(|n| n.to_string_lossy() == target))
.cloned()
.collect();
match matches.len() {
0 => {}
1 => return Resolution::One(matches.into_iter().next().unwrap()),
_ => return Resolution::Many(matches),
}
}
let file_str: Vec<String> = files.iter().map(|f| to_slash(f)).collect();
match fuzzy_find(filename, &file_str) {
Some(hits) => {
let top = hits[0].1;
let tied: Vec<PathBuf> = hits
.iter()
.take_while(|(_, score)| *score == top)
.map(|(path, _)| PathBuf::from(path.as_str()))
.collect();
if tied.len() == 1 {
Resolution::One(tied.into_iter().next().unwrap())
} else {
Resolution::Many(tied)
}
}
None => Resolution::None,
}
}
enum Resolved {
Path(PathBuf),
Cancelled,
NotFound,
}
fn rel_display(path: &Path, root: &Path) -> String {
let shown = path.strip_prefix(root).unwrap_or(path);
sanitize(&to_slash(shown))
}
fn cancelled() -> Result<(), String> {
println!("cancelled");
Ok(())
}
fn resolve_one(filename: &str) -> Result<Resolved, String> {
let files = match list(true) {
Some(files) => files,
None => return Ok(Resolved::NotFound),
};
let root = read_config_file().and_then(|c| c.get_root_dir())?;
match classify(filename, &root, &files) {
Resolution::One(path) => Ok(Resolved::Path(path)),
Resolution::None => Ok(Resolved::NotFound),
Resolution::Many(candidates) => {
let labels: Vec<String> = candidates.iter().map(|c| rel_display(c, &root)).collect();
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
let mut msg = format!(
"'{}' is ambiguous, {} contracts match:\n",
sanitize(filename),
labels.len()
);
for label in &labels {
msg.push_str(&format!(" {label}\n"));
}
msg.push_str(&format!("Specify the path, e.g. -f {}", labels[0]));
return Err(msg);
}
let prompt = format!(
"{} contracts match \"{}\":",
candidates.len(),
sanitize(filename)
);
match picker::pick(&prompt, &labels).map_err(|err| format!("picker failed: {err}"))? {
Some(idx) => Ok(Resolved::Path(candidates[idx].clone())),
None => Ok(Resolved::Cancelled),
}
}
}
}
fn read_cmd(filename: &str, status: Option<u16>, example: bool) -> Result<(), String> {
match resolve_one(filename)? {
Resolved::Path(path) => match read_file(&path) {
Ok(content) => read(&content, status, example),
Err(err) => {
eprintln!("Failed to read {}: {}", path.display(), err);
println!("No contract found");
Ok(())
}
},
Resolved::Cancelled => cancelled(),
Resolved::NotFound => {
println!("No contract found");
Ok(())
}
}
}
fn read(content: &str, status: Option<u16>, example: bool) -> Result<(), String> {
match json_get(content, status) {
Ok(contract) => {
render(&contract, example);
if let Some(status) = status
&& contract.responses.is_empty()
{
println!("\n No response with status {status}");
}
Ok(())
}
Err(err) => Err(err.to_string()),
}
}
fn validate_cmd(template: bool, find: Option<&str>) -> Result<(), String> {
if template {
return validate_template_cmd();
}
let files = match list(true) {
Some(files) => files,
None => {
println!("No contracts found");
return Ok(());
}
};
let root = read_config_file().and_then(|c| c.get_root_dir()).ok();
let targets: Vec<PathBuf> = match find {
Some(name) if name.ends_with('/') => {
let base = root
.clone()
.ok_or("Not in an apic project (run `apic init`)")?;
let dir = confine_to_dir(&base, Path::new(name))?;
if !dir.is_dir() {
eprintln!("No such folder: {}", sanitize(name));
std::process::exit(1);
}
let in_dir: Vec<PathBuf> = files
.iter()
.filter(|f| f.starts_with(&dir))
.cloned()
.collect();
if in_dir.is_empty() {
println!("No contracts found under {}", sanitize(name));
return Ok(());
}
in_dir
}
Some(name) => match resolve_one(name)? {
Resolved::Path(path) => vec![path],
Resolved::Cancelled => return cancelled(),
Resolved::NotFound => {
eprintln!("No contract matches {}", sanitize(name));
std::process::exit(1);
}
},
None => files,
};
let mut failed = 0usize;
for path in &targets {
let shown = root
.as_ref()
.and_then(|r| path.strip_prefix(r).ok())
.unwrap_or(path);
let shown = sanitize(&to_slash(shown));
let result = read_file(path)
.map_err(|err| err.to_string())
.and_then(|content| validate_contract(&content).map_err(|err| err.to_string()));
match result {
Ok(()) => println!("ok {shown}"),
Err(err) => {
println!("FAIL {shown}: {}", sanitize(&err));
failed += 1;
}
}
}
println!("\n{} passed, {} failed", targets.len() - failed, failed);
if failed > 0 {
std::process::exit(1);
}
Ok(())
}
fn validate_template_cmd() -> Result<(), String> {
match crate::template::check_template() {
crate::template::TemplateCheck::Absent => {
println!("No project template found; create will use the built-in template");
Ok(())
}
crate::template::TemplateCheck::Valid => {
println!("ok .apic/template.json");
Ok(())
}
crate::template::TemplateCheck::Invalid(reason) => {
println!("FAIL .apic/template.json: {}", sanitize(&reason));
std::process::exit(1);
}
}
}
fn create_cmd(filename: &str, editor: Option<&str>) -> Result<(), String> {
let path = match read_config_file().and_then(|conf| conf.get_root_dir()) {
Ok(root) => confine_to_dir(&root, Path::new(filename))?,
Err(_) => PathBuf::from(filename),
};
if path.exists() {
return Err(format!("{} already exists", path.display()));
}
if editor.is_some() {
let contract = crate::template::resolve_for_create()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("Failed to create {}: {}", parent.display(), err))?;
}
fs::write(&path, contract)
.map_err(|err| format!("Failed to write {}: {}", path.display(), err))?;
println!("Created {}", sanitize(&path.to_string_lossy()));
return open_in_editor(&path, editor)
.map_err(|err| format!("Failed to open editor: {err}"));
}
let overlay = read_project_template();
let model = crate::tui::seed_model(overlay.as_deref())?;
crate::tui::run(model, &path)
}
fn read_project_template() -> Option<String> {
let apic_dir = crate::config::find_apic_dir()?;
crate::template::seed_if_missing(&apic_dir).ok()?;
fs::read_to_string(crate::template::path(&apic_dir)).ok()
}
fn open_cmd(template: bool, filename: Option<&str>, editor: Option<&str>) -> Result<(), String> {
if template {
let apic_dir =
crate::config::find_apic_dir().ok_or("Not in an apic project (run `apic init`)")?;
crate::template::seed_if_missing(&apic_dir)?;
let path = crate::template::path(&apic_dir);
if editor.is_some() {
return open_in_editor(&path, editor)
.map_err(|err| format!("Failed to open editor: {err}"));
}
let overlay =
read_file(&path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
let contract = crate::template::merge_onto_default(&overlay)
.map_err(|reason| format!("{} {reason}", path.display()))?;
let parsed = json_get(&contract, None)
.map_err(|err| format!("{} is not a valid contract: {err}", path.display()))?;
let model = crate::tui::EditModel::from_contract(parsed);
return crate::tui::run(model, &path);
}
let filename = filename.expect("a find query is required without --template");
match resolve_one(filename)? {
Resolved::Path(path) => {
if editor.is_some() {
open_in_editor(&path, editor).map_err(|err| format!("Failed to open editor: {err}"))
} else {
open_path_in_tui(&path)
}
}
Resolved::Cancelled => cancelled(),
Resolved::NotFound => Err(format!("No contract found matching '{filename}'")),
}
}
fn open_path_in_tui(path: &Path) -> Result<(), String> {
let text =
read_file(path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
let contract = json_get(&text, None)
.map_err(|err| format!("{} is not a valid contract: {err}", path.display()))?;
let model = crate::tui::EditModel::from_contract(contract);
crate::tui::run(model, path)
}
fn remove_cmd(filename: &str) -> Result<(), String> {
match resolve_one(filename)? {
Resolved::Path(path) => {
let root = read_config_file().and_then(|c| c.get_root_dir())?;
let shown = rel_display(&path, &root);
if !confirm(&format!("Remove {shown}?"))? {
return cancelled();
}
fs::remove_file(&path).map_err(|err| format!("Failed to remove {shown}: {err}"))?;
println!("Removed {shown}");
Ok(())
}
Resolved::Cancelled => cancelled(),
Resolved::NotFound => Err(format!("No contract found matching '{filename}'")),
}
}
fn convert_cmd(postman: &Path, destination: Option<&str>) -> Result<(), String> {
let root = read_config_file().and_then(|conf| conf.get_root_dir())?;
let dest_base = match destination {
Some(dir) => confine_to_dir(&root, Path::new(dir))?,
None => root,
};
crate::convert::run(postman, &dest_base)
}
fn confirm(prompt: &str) -> Result<bool, String> {
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
return Ok(true);
}
print!("{prompt} [y/N] ");
std::io::stdout()
.flush()
.map_err(|err| format!("Failed to write prompt: {err}"))?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|err| format!("Failed to read input: {err}"))?;
let answer = answer.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn open_in_editor(path: &Path, editor: Option<&str>) -> std::io::Result<()> {
let user_editor = editor
.map(String::from)
.or_else(|| std::env::var("VISUAL").ok())
.or_else(|| std::env::var("EDITOR").ok())
.unwrap_or_else(|| String::from("vi"));
let mut parts = user_editor.split_whitespace();
let program = parts.next().unwrap_or("vi");
let status = std::process::Command::new(program)
.args(parts)
.arg(path)
.status()?;
if !status.success() {
eprintln!("Editor exited with non-zero status: {}", status);
}
Ok(())
}
fn list_cmd(filter: Option<&str>, absolute: bool) -> Result<(), String> {
if let Some(files) = list(absolute) {
struct Row {
rel: String,
indices: Vec<usize>,
score: i32,
shown: String,
}
let is_tty = std::io::stdout().is_terminal();
let root = read_config_file().and_then(|c| c.get_root_dir()).ok();
let mut rows: Vec<Row> = files
.iter()
.filter_map(|file| {
let rel = root
.as_ref()
.and_then(|r| file.strip_prefix(r).ok())
.unwrap_or(file);
let rel = sanitize(&to_slash(rel));
let (score, indices) = match &filter {
Some(query) => fuzzy_match_path(query, &rel)?,
None => (0, Vec::new()),
};
let shown = sanitize(&to_slash(file));
Some(Row {
rel,
indices,
score,
shown,
})
})
.collect();
if rows.is_empty() {
} else if is_tty {
let mut tree_root = tree::Node::default();
for row in &rows {
tree_root.insert(Path::new(&row.rel), &row.indices);
}
let root_label = if absolute {
root.as_ref()
.map(|r| format!("{}/", sanitize(&to_slash(r))))
} else {
None
};
print!("{}", tree::render(root_label.as_deref(), &tree_root, true));
} else {
if filter.is_some() {
rows.sort_by_key(|row| std::cmp::Reverse(row.score));
}
for row in rows {
println!("{}", row.shown);
}
}
}
Ok(())
}
pub fn run() {
let cli = Cli::parse();
let result: Result<(), String> = match cli.command {
Commands::Config { set_dir } => update_working_dir(set_dir.as_deref()),
Commands::Create { filename, editor } => match filename {
Some(filename) => create_cmd(&filename, editor.as_deref()),
None => Err("no filename provided, use 'apic create -f <filename>'".to_string()),
},
Commands::Init { set_dir } => init_cmd(set_dir.as_deref()),
Commands::List { filter, absolute } => list_cmd(filter.as_deref(), absolute),
Commands::Read {
find,
status,
example,
} => read_cmd(&find, status, example),
Commands::Validate { find, template } => validate_cmd(template, find.as_deref()),
Commands::Open {
find,
editor,
template,
} => open_cmd(template, find.as_deref(), editor.as_deref()),
Commands::Remove { find } => remove_cmd(&find),
Commands::Convert {
postman,
destination,
} => convert_cmd(&postman, destination.as_deref()),
};
if let Err(err) = result {
eprintln!("Error: {err}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("apic_test_cli_{tag}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn fake(root: &str, rels: &[&str]) -> (PathBuf, Vec<PathBuf>) {
let root = PathBuf::from(root);
let files = rels.iter().map(|r| root.join(r)).collect();
(root, files)
}
#[test]
fn classify_exact_path_wins_even_when_basenames_tie() {
let root = temp_dir("exact");
fs::create_dir_all(root.join("user")).unwrap();
fs::create_dir_all(root.join("auth")).unwrap();
fs::write(root.join("user/user.json"), "{}").unwrap();
fs::write(root.join("auth/user.json"), "{}").unwrap();
let files = vec![root.join("user/user.json"), root.join("auth/user.json")];
for query in ["user/user.json", "user/user"] {
match classify(query, &root, &files) {
Resolution::One(path) => assert_eq!(path, root.join("user/user.json")),
other => panic!("expected One for {query}, got {other:?}"),
}
}
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn classify_basename_tie_returns_many_with_all_ties() {
let (root, files) = fake(
"/apic_no_such_root",
&["user/user.json", "auth/user.json", "user/profile/user.json"],
);
match classify("user", &root, &files) {
Resolution::Many(paths) => {
assert_eq!(paths.len(), 3);
assert!(paths.contains(&root.join("auth/user.json")));
}
other => panic!("expected Many, got {other:?}"),
}
}
#[test]
fn classify_single_basename_match_returns_one() {
let (root, files) = fake("/apic_no_such_root", &["user/user.json", "auth/login.json"]);
for query in ["user", "user.json"] {
match classify(query, &root, &files) {
Resolution::One(path) => assert_eq!(path, root.join("user/user.json")),
other => panic!("expected One for {query}, got {other:?}"),
}
}
}
#[test]
fn classify_query_with_separator_skips_basename_matching() {
let (root, files) = fake("/proj", &["a/user.json", "b/user.json"]);
match classify("a/user", &root, &files) {
Resolution::One(path) => assert_eq!(path, root.join("a/user.json")),
other => panic!("expected One, got {other:?}"),
}
}
#[test]
fn classify_fuzzy_tie_returns_many_with_top_scorers() {
let (root, files) = fake("/proj", &["a/user-a.json", "b/user-b.json"]);
match classify("usr", &root, &files) {
Resolution::Many(paths) => assert_eq!(paths.len(), 2),
other => panic!("expected Many, got {other:?}"),
}
}
#[test]
fn classify_distinct_fuzzy_winner_returns_one() {
let (root, files) = fake("/proj", &["a/user.json", "b/zzz.json"]);
match classify("usr", &root, &files) {
Resolution::One(path) => assert_eq!(path, root.join("a/user.json")),
other => panic!("expected One, got {other:?}"),
}
}
#[test]
fn classify_no_match_returns_none() {
let (root, files) = fake("/proj", &["a/user.json"]);
assert!(matches!(classify("qqqq", &root, &files), Resolution::None));
}
}