use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
pub fn expand_manifest_paths(inputs: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut out: BTreeSet<PathBuf> = BTreeSet::new();
for input in inputs {
let s = input.to_string_lossy();
if s.contains(['*', '?', '[']) {
let pattern = s.replace('\\', "/");
let mut matched = 0usize;
for entry in glob::glob(&pattern).with_context(|| format!("bad glob pattern '{s}'"))? {
let path = entry.with_context(|| format!("glob '{s}'"))?;
if path.is_file() {
out.insert(path);
matched += 1;
}
}
if matched == 0 {
bail!("glob '{s}' matched no files");
}
} else if input.is_dir() {
let mut found = 0usize;
for entry in
std::fs::read_dir(input).with_context(|| format!("read dir {}", input.display()))?
{
let path = entry?.path();
if path.is_file() && has_yaml_ext(&path) {
out.insert(path);
found += 1;
}
}
if found == 0 {
bail!("directory {} has no .yaml / .yml files", input.display());
}
} else {
out.insert(input.clone());
}
}
Ok(out.into_iter().collect())
}
fn has_yaml_ext(p: &Path) -> bool {
matches!(
p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("yaml") | Some("yml")
)
}
pub async fn export(
base: &str,
kind: &str,
id: Option<String>,
all: bool,
out_dir: Option<PathBuf>,
) -> Result<()> {
if all {
let Some(dir) = out_dir else {
bail!("--all requires --out-dir <dir>");
};
let ids = list_ids(base, kind).await?;
if ids.is_empty() {
println!("no {kind} registered");
return Ok(());
}
std::fs::create_dir_all(&dir)
.with_context(|| format!("create out dir {}", dir.display()))?;
let mut failures = 0usize;
for id in &ids {
match fetch_yaml(base, kind, id)
.await
.and_then(|y| write_yaml(&dir, id, &y))
{
Ok(path) => println!("✓ {id} → {}", path.display()),
Err(e) => {
eprintln!("✗ {id}: {e:#}");
failures += 1;
}
}
}
if failures > 0 {
bail!("{failures}/{} {kind} failed to export", ids.len());
}
Ok(())
} else {
let Some(id) = id else {
bail!("provide an id to export, or --all --out-dir <dir>");
};
let yaml = fetch_yaml(base, kind, &id).await?;
match out_dir {
Some(dir) => {
std::fs::create_dir_all(&dir)
.with_context(|| format!("create out dir {}", dir.display()))?;
let path = write_yaml(&dir, &id, &yaml)?;
println!("✓ {id} → {}", path.display());
}
None => print!("{yaml}"),
}
Ok(())
}
}
async fn fetch_yaml(base: &str, kind: &str, id: &str) -> Result<String> {
let url = format!("{base}/api/{kind}/{id}/yaml");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("export '{id}' failed: {status} — {body}");
}
resp.text()
.await
.with_context(|| format!("read body of {url}"))
}
async fn list_ids(base: &str, kind: &str) -> Result<Vec<String>> {
let url = format!("{base}/api/{kind}");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
bail!("list {kind} failed: {}", resp.status());
}
let rows: Vec<serde_json::Value> = resp
.json()
.await
.with_context(|| format!("parse JSON response from {url}"))?;
Ok(rows
.iter()
.filter_map(|r| r.get("id").and_then(|v| v.as_str()).map(String::from))
.collect())
}
fn write_yaml(dir: &Path, id: &str, yaml: &str) -> Result<PathBuf> {
if id.contains(['/', '\\', '\0']) || id == "." || id == ".." {
bail!("refusing to write unsafe id (path separators / dot components / NUL): '{id}'");
}
let path = dir.join(format!("{id}.yaml"));
std::fs::write(&path, yaml).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn directory_expands_to_yaml_and_yml_only() {
let tmp = std::env::temp_dir().join(format!("kanade-bulk-dir-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
write(&tmp, "a.yaml", "id: a");
write(&tmp, "b.yml", "id: b");
write(&tmp, "README.md", "nope");
let got = expand_manifest_paths(std::slice::from_ref(&tmp)).unwrap();
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["a.yaml", "b.yml"]);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn glob_matches_and_dedupes_against_explicit_path() {
let tmp = std::env::temp_dir().join(format!("kanade-bulk-glob-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let a = write(&tmp, "one.yaml", "id: one");
write(&tmp, "two.yaml", "id: two");
let pattern = PathBuf::from(format!("{}/*.yaml", tmp.display()));
let got = expand_manifest_paths(&[pattern, a]).unwrap();
assert_eq!(got.len(), 2);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn plain_nonexistent_file_passes_through() {
let got = expand_manifest_paths(&[PathBuf::from("does-not-exist.yaml")]).unwrap();
assert_eq!(got, vec![PathBuf::from("does-not-exist.yaml")]);
}
#[test]
fn glob_matching_nothing_errors() {
let tmp = std::env::temp_dir().join(format!("kanade-bulk-empty-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let pattern = PathBuf::from(format!("{}/*.yaml", tmp.display()));
assert!(expand_manifest_paths(&[pattern]).is_err());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn directory_with_no_yaml_errors() {
let tmp = std::env::temp_dir().join(format!("kanade-bulk-nodir-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("README.md"), "nope").unwrap();
assert!(expand_manifest_paths(std::slice::from_ref(&tmp)).is_err());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn write_yaml_rejects_unsafe_ids() {
let tmp = std::env::temp_dir();
assert!(write_yaml(&tmp, "../escape", "x").is_err());
assert!(write_yaml(&tmp, "a/b", "x").is_err());
assert!(write_yaml(&tmp, "a\\b", "x").is_err());
assert!(write_yaml(&tmp, ".", "x").is_err());
assert!(write_yaml(&tmp, "..", "x").is_err());
assert!(write_yaml(&tmp, "a\0b", "x").is_err());
}
}