use crate::http::discover;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Source {
pub name: String,
pub kind: String,
pub url: String,
pub out: PathBuf,
pub base_url_override: Option<String>,
}
pub fn load(workspace: &Path) -> Result<Option<Vec<Source>>, String> {
let candidates = [
workspace.join(".mnml").join("sources.json"),
workspace.join(".rqst").join("sources.json"),
];
let path = candidates.iter().find(|p| p.exists());
let Some(path) = path else {
return Ok(None);
};
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let arr: Vec<serde_json::Value> =
serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
let mut out: Vec<Source> = Vec::with_capacity(arr.len());
for v in &arr {
let name = v
.get("name")
.and_then(|s| s.as_str())
.unwrap_or("(unnamed)")
.to_string();
let kind = v
.get("kind")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let Some(url) = v.get("url").and_then(|s| s.as_str()) else {
eprintln!("mnml http sync: source `{name}` missing 'url' — skipping");
continue;
};
let url = url.to_string();
let out_path = v
.get("out")
.and_then(|s| s.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| workspace.join(".rqst").join("requests").join(&name));
let resolved = if out_path.is_absolute() {
out_path
} else {
workspace.join(out_path)
};
let base_url_override = v
.get("base_url_override")
.and_then(|s| s.as_str())
.map(str::to_string);
out.push(Source {
name,
kind,
url,
out: resolved,
base_url_override,
});
}
Ok(Some(out))
}
pub fn check_sync(workspace: &Path) -> Result<(String, usize), String> {
check_sync_with_normalize(workspace, false)
}
pub fn check_sync_with_normalize(
workspace: &Path,
normalize: bool,
) -> Result<(String, usize), String> {
let sources = match load(workspace)? {
Some(s) if !s.is_empty() => s,
Some(_) => return Err("sources.json is empty".into()),
None => {
return Err(format!(
"no sources.json found at {} or {}",
workspace.join(".mnml").join("sources.json").display(),
workspace.join(".rqst").join("sources.json").display(),
));
}
};
let mut trace = String::new();
trace.push_str("# http.sync_check — drift report\n\n");
let mut total_drift: usize = 0;
for s in &sources {
if s.kind != "swagger" {
trace.push_str(&format!(
"## {}\n skipping unsupported kind '{}'\n\n",
s.name, s.kind
));
continue;
}
trace.push_str(&format!(
"## {}\n spec: {}\n compared against: {}\n",
s.name,
s.url,
s.out.display()
));
let tmp = match tempfile::TempDir::new() {
Ok(t) => t,
Err(e) => {
trace.push_str(&format!(" ERR: tempdir: {e}\n\n"));
continue;
}
};
let dargs = discover::Options {
spec: s.url.clone(),
out: tmp.path().to_path_buf(),
base_url: s.base_url_override.clone(),
normalize,
edge_cases: false,
force: true,
};
if let Err(e) = discover::run(&dargs) {
trace.push_str(&format!(" ERR: discover: {e}\n\n"));
continue;
}
let generated = walk_curls(tmp.path());
let existing = walk_curls(&s.out);
let mut added: Vec<PathBuf> = generated
.keys()
.filter(|p| !existing.contains_key(*p))
.cloned()
.collect();
let mut removed: Vec<PathBuf> = existing
.keys()
.filter(|p| !generated.contains_key(*p))
.cloned()
.collect();
let mut changed: Vec<PathBuf> = generated
.iter()
.filter(|(p, new)| existing.get(*p).is_some_and(|old| old != *new))
.map(|(p, _)| p.clone())
.collect();
added.sort();
removed.sort();
changed.sort();
let drift = added.len() + removed.len() + changed.len();
total_drift += drift;
if drift == 0 {
trace.push_str(" clean — no drift\n\n");
continue;
}
trace.push_str(&format!(
" drift: {} added, {} removed, {} changed\n",
added.len(),
removed.len(),
changed.len()
));
for p in &added {
trace.push_str(&format!(" + {}\n", p.display()));
}
for p in &removed {
trace.push_str(&format!(" - {}\n", p.display()));
}
for p in &changed {
trace.push_str(&format!(" ~ {}\n", p.display()));
}
trace.push('\n');
}
trace.push_str(&format!(
"# summary — {} file(s) differ across all sources\n",
total_drift
));
if total_drift > 0 {
trace.push_str("# run `:http.sync` to apply (overwrites existing stubs)\n");
}
Ok((trace, total_drift))
}
fn walk_curls(dir: &Path) -> std::collections::HashMap<PathBuf, String> {
let mut out = std::collections::HashMap::new();
fn walk(root: &Path, cur: &Path, acc: &mut std::collections::HashMap<PathBuf, String>) {
let Ok(rd) = fs::read_dir(cur) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
walk(root, &p, acc);
} else if p.extension().and_then(|e| e.to_str()) == Some("curl")
&& let Ok(text) = fs::read_to_string(&p)
&& let Ok(rel) = p.strip_prefix(root)
{
acc.insert(rel.to_path_buf(), text);
}
}
}
walk(dir, dir, &mut out);
out
}
pub fn run_sync(workspace: &Path) -> Result<(String, usize), String> {
run_sync_with_normalize(workspace, false)
}
pub fn run_sync_with_normalize(
workspace: &Path,
normalize: bool,
) -> Result<(String, usize), String> {
let sources = match load(workspace)? {
Some(s) if !s.is_empty() => s,
Some(_) => return Err("sources.json is empty".into()),
None => {
return Err(format!(
"no sources.json found at {} or {}",
workspace.join(".mnml").join("sources.json").display(),
workspace.join(".rqst").join("sources.json").display(),
));
}
};
let mut trace = String::new();
let mut total: usize = 0;
let mut ran: usize = 0;
for s in &sources {
if s.kind != "swagger" {
trace.push_str(&format!(
"[sync] {}: skipping unsupported kind '{}'\n",
s.name, s.kind
));
continue;
}
trace.push_str(&format!(
"[sync] {}: fetch {} → {}\n",
s.name,
s.url,
s.out.display()
));
let dargs = discover::Options {
spec: s.url.clone(),
out: s.out.clone(),
base_url: s.base_url_override.clone(),
normalize,
edge_cases: false,
force: true,
};
match discover::run(&dargs) {
Ok((n, _skipped)) => {
trace.push_str(&format!("[sync] {}: wrote {n} stub(s)\n", s.name));
total += n;
ran += 1;
}
Err(e) => {
trace.push_str(&format!("[sync] {}: failed — {e}\n", s.name));
}
}
}
if let Some(seeded) = maybe_seed_env_example(workspace) {
trace.push_str(&format!("\n[sync] seeded {}\n", seeded.display()));
}
trace.push_str(&format!(
"\n[sync] done — {ran} source(s), {total} request stub(s) total\n"
));
Ok((trace, total))
}
fn maybe_seed_env_example(workspace: &Path) -> Option<PathBuf> {
let dir = workspace.join(".mnml").join("env");
let path = dir.join("dev.env.example");
if path.exists() {
return None;
}
if fs::create_dir_all(&dir).is_err() {
return None;
}
let mut contents = String::from(
"# Seeded by `mnml sync` on first run — 2026-07-09.\n\
# Well-known env vars that discover-generated stubs reference.\n\
# Copy to `dev.env` and fill in values for your workspace.\n\
\n\
BASE_URL=\n\
TOKEN=\n\
\n",
);
for v in crate::http::faker::known_env_vars() {
contents.push_str(&format!("{v}=\n"));
}
fs::write(&path, contents).ok().map(|_| path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maybe_seed_env_example_writes_starter_file_on_first_run() {
let dir = tempfile::tempdir().unwrap();
let out = maybe_seed_env_example(dir.path()).expect("should write");
let text = fs::read_to_string(&out).unwrap();
assert!(text.contains("BASE_URL="), "starter contents: {text}");
assert!(text.contains("TOKEN="), "starter contents: {text}");
assert!(text.contains("MERCHANT_ID="), "starter contents: {text}");
assert!(text.contains("LOCATION_ID="), "starter contents: {text}");
}
#[test]
fn maybe_seed_env_example_leaves_existing_file_alone() {
let dir = tempfile::tempdir().unwrap();
let env_dir = dir.path().join(".mnml").join("env");
fs::create_dir_all(&env_dir).unwrap();
let path = env_dir.join("dev.env.example");
fs::write(&path, "USER_TUNED=1\n").unwrap();
let result = maybe_seed_env_example(dir.path());
assert!(result.is_none(), "should not touch existing file");
assert_eq!(fs::read_to_string(&path).unwrap(), "USER_TUNED=1\n");
}
}