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 fn split_yaml_documents(text: &str) -> Vec<String> {
if !has_doc_separator(text) {
return if has_yaml_content(text) {
vec![text.to_string()]
} else {
Vec::new()
};
}
let mut docs = Vec::new();
let mut cur = String::new();
for line in text.lines() {
if is_doc_separator(line) {
docs.push(std::mem::take(&mut cur));
} else {
cur.push_str(line);
cur.push('\n');
}
}
docs.push(cur);
let filtered: Vec<String> = docs.into_iter().filter(|d| has_yaml_content(d)).collect();
if filtered.len() <= 1 {
return if has_yaml_content(text) {
vec![text.to_string()]
} else {
Vec::new()
};
}
filtered
}
fn is_doc_separator(line: &str) -> bool {
let Some(rest) = line.strip_prefix("---") else {
return false;
};
match rest.chars().next() {
None => true,
Some(' ') | Some('\t') => {
let after = rest.trim_start_matches([' ', '\t']);
after.is_empty() || after.starts_with('#')
}
_ => false,
}
}
fn has_doc_separator(text: &str) -> bool {
text.lines().any(is_doc_separator)
}
fn has_yaml_content(doc: &str) -> bool {
doc.lines().any(|l| {
let t = l.trim_start();
!t.is_empty() && !t.starts_with('#')
})
}
pub async fn export(
base: &str,
kind: &str,
id: Option<String>,
all: bool,
out_dir: Option<PathBuf>,
) -> Result<()> {
if all {
let ids = list_ids(base, kind).await?;
if ids.is_empty() {
eprintln!("no {kind} registered");
return Ok(());
}
let Some(dir) = out_dir else {
return export_all_bundle(base, kind, &ids).await;
};
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 export_all_bundle(base: &str, kind: &str, ids: &[String]) -> Result<()> {
let mut failures = 0usize;
let mut first = true;
for id in ids {
match fetch_yaml(base, kind, id).await {
Ok(yaml) => {
if !first {
println!("---");
}
first = false;
print!("{yaml}");
if !yaml.ends_with('\n') {
println!();
}
}
Err(e) => {
eprintln!("✗ {id}: {e:#}");
failures += 1;
}
}
}
if failures > 0 {
bail!("{failures}/{} {kind} failed to export", ids.len());
}
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 single_document_is_returned_verbatim() {
let src = "id: a\r\nversion: \"1\"\r\n# trailing comment\r\n";
assert_eq!(split_yaml_documents(src), vec![src.to_string()]);
}
#[test]
fn single_document_with_leading_separator_is_returned_verbatim() {
let leading = "---\nid: a\nversion: \"1\"\n";
assert_eq!(split_yaml_documents(leading), vec![leading.to_string()]);
let both = "---\r\nid: a\r\n---\r\n";
assert_eq!(split_yaml_documents(both), vec![both.to_string()]);
}
#[test]
fn splits_on_column_zero_separators() {
let src = "id: a\n---\nid: b\n---\nid: c\n";
assert_eq!(
split_yaml_documents(src),
vec!["id: a\n", "id: b\n", "id: c\n"]
);
}
#[test]
fn marker_with_trailing_inline_comment_still_splits() {
let src = "id: a\n--- # job-b\nid: b\n";
assert_eq!(split_yaml_documents(src), vec!["id: a\n", "id: b\n"]);
}
#[test]
fn marker_with_inline_node_content_is_not_a_separator() {
let src = "id: a\n--- id: b\n";
assert_eq!(split_yaml_documents(src), vec![src.to_string()]);
}
#[test]
fn leading_and_trailing_separators_do_not_make_empty_docs() {
let src = "# bundle header\n---\nid: a\n---\nid: b\n---\n";
assert_eq!(split_yaml_documents(src), vec!["id: a\n", "id: b\n"]);
}
#[test]
fn indented_triple_dash_inside_script_is_not_a_separator() {
let src = "\
id: a
execute:
script: |
echo start
---
echo end
---
id: b
";
let docs = split_yaml_documents(src);
assert_eq!(docs.len(), 2, "got: {docs:?}");
assert!(
docs[0].contains(" ---"),
"indented --- kept: {:?}",
docs[0]
);
assert!(docs[1].starts_with("id: b"));
}
#[test]
fn separator_with_trailing_whitespace_still_splits() {
let src = "id: a\n--- \nid: b\n";
assert_eq!(split_yaml_documents(src), vec!["id: a\n", "id: b\n"]);
}
#[test]
fn comment_or_blank_only_source_yields_no_documents() {
assert!(split_yaml_documents("# just a comment\n\n").is_empty());
assert!(split_yaml_documents(" \n\t\n").is_empty());
assert!(split_yaml_documents("").is_empty());
}
#[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());
}
}