use std::path::{Path, PathBuf};
use std::io::Write;
use std::fs::OpenOptions;
use std::fs::File;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use cleanlib_client::{config, proxy};
fn dedup_preserving_order(input: &[String]) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::with_capacity(input.len());
for s in input {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
continue;
}
if seen.insert(trimmed.clone()) {
out.push(trimmed);
}
}
out
}
pub fn create_backup(target: &Path, force: bool) -> Result<()> {
if let Some(parent) = target.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if target.exists() && !force {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup_path = target.with_extension(format!(
"{}cleanlib-backup-{}",
target
.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default(),
ts
));
std::fs::copy(target, &backup_path)?;
eprintln!(
"backed up existing {} → {}",
target.display(),
backup_path.display()
);
}
Ok(())
}
pub fn write_file(target: &Path, blob: &str, append: bool) -> Result<()> {
if append {
let mut file: File = OpenOptions::new()
.append(true) .create(true) .open(&target)?;
file.write_all(blob.as_bytes())?;
file.flush()?;
} else {
let mut file: File = OpenOptions::new()
.truncate(true)
.write(true)
.create(true)
.open(&target)?;
file.write_all(blob.as_bytes())?;
file.flush()?;
}
Ok(())
}
pub fn run(
ecosystems: Vec<String>,
scope: Option<String>,
write: bool,
write_to: Option<PathBuf>,
inline_token: bool,
force: bool,
) -> Result<()> {
let path = config::default_path();
let cfg = config::load_with_env_overrides(path.as_deref())?;
if inline_token {
let has_usable_key = cfg
.auth
.api_key
.as_deref()
.map(|k| !k.trim().is_empty())
.unwrap_or(false);
if !has_usable_key {
anyhow::bail!(
"CLIENT_INLINE_TOKEN_NO_KEY — `--inline-token` requires a non-empty API key.\n\
Run `cleanlib login --api-key <KEY>` first, set CLEANLIB_ENRICH_BEARER in your\n\
environment, or drop `--inline-token` to emit a `${{CLEANLIBRARY_API_KEY}}`\n\
placeholder instead (resolves at runtime from the env tier)."
);
}
}
let opts = proxy::EmitOptions {
endpoint: cfg.endpoint.url.clone(),
scope,
inline_token,
api_key: cfg.auth.api_key.clone(),
};
let write_mode = write || write_to.is_some();
let ecosystems = dedup_preserving_order(&ecosystems);
let mut prepared: Vec<(proxy::Ecosystem, proxy::ProxyConfig)> = Vec::with_capacity(ecosystems.len());
for eco_str in &ecosystems {
let eco = proxy::Ecosystem::parse(eco_str).ok_or_else(|| {
anyhow::anyhow!(
"cleanlib config init does not yet support ecosystem '{}' — supported ecosystems: '{}'.\nConfigure proxy manually for now.",
eco_str,
proxy::Ecosystem::supported_list()
)
})?;
let proxy_cfg = proxy::emit(eco, &opts)?;
prepared.push((eco, proxy_cfg));
}
let mut backup: Option<PathBuf> = None;
let mut first_write = true;
for (eco, proxy_cfg) in prepared {
if !write_mode {
println!(
"# === {} ({}) ===",
eco.as_str(),
if proxy_cfg.canonical_location.as_os_str().is_empty() {
"no canonical config file; shell-snippet form".to_string()
} else {
proxy_cfg.canonical_location.display().to_string()
}
);
print!("{}", proxy_cfg.config_blob);
println!();
continue;
}
let target: PathBuf =
match (&write_to, proxy_cfg.canonical_location.as_os_str().is_empty()) {
(Some(p), _) => p.clone(),
(None, true) => {
eprintln!(
"# {}: no canonical config file; printing shell-snippet to stdout",
eco.as_str()
);
print!("{}", proxy_cfg.config_blob);
continue;
}
(None, false) => proxy_cfg.canonical_location.clone(),
};
if Some(&target) != backup.as_ref() {
create_backup(&target, force)?;
backup = Some(target.clone());
}
if write && write_to.is_none() {
write_file(&target, &proxy_cfg.config_blob, false)?;
} else if first_write {
write_file(&target, &proxy_cfg.config_blob, false)?;
first_write = false;
} else {
write_file(&target, &proxy_cfg.config_blob, true)?;
}
eprintln!("wrote {} ({} bytes)", target.display(), proxy_cfg.config_blob.len());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn dedup_strips_consecutive_duplicates() {
let input = vec!["npm".to_string(), "npm".to_string()];
assert_eq!(dedup_preserving_order(&input), vec!["npm".to_string()]);
}
#[test]
fn dedup_strips_interleaved_duplicates() {
let input = vec![
"npm".to_string(),
"pypi".to_string(),
"npm".to_string(),
"go".to_string(),
"pypi".to_string(),
];
assert_eq!(
dedup_preserving_order(&input),
vec!["npm".to_string(), "pypi".to_string(), "go".to_string()]
);
}
#[test]
fn dedup_preserves_first_occurrence_order() {
let input = vec!["go".to_string(), "npm".to_string(), "pypi".to_string()];
assert_eq!(
dedup_preserving_order(&input),
vec!["go".to_string(), "npm".to_string(), "pypi".to_string()]
);
}
#[test]
fn dedup_trims_whitespace_and_drops_empty() {
let input = vec![
"npm".to_string(),
" pypi ".to_string(),
"".to_string(),
" ".to_string(),
"pypi".to_string(),
];
assert_eq!(
dedup_preserving_order(&input),
vec!["npm".to_string(), "pypi".to_string()]
);
}
#[test]
fn dedup_empty_input_yields_empty() {
let input: Vec<String> = Vec::new();
assert!(dedup_preserving_order(&input).is_empty());
}
#[test]
fn write_file_truncate_creates_fresh_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
write_file(&path, "npm-snippet\n", false).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "npm-snippet\n");
}
#[test]
fn write_file_truncate_replaces_prior_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
write_file(&path, "old\n", false).unwrap();
write_file(&path, "new\n", false).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
}
#[test]
fn write_file_append_accumulates_after_truncate() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
write_file(&path, "npm-snippet\n", false).unwrap();
write_file(&path, "pypi-snippet\n", true).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"npm-snippet\npypi-snippet\n"
);
}
#[test]
fn write_file_append_on_nonexistent_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
write_file(&path, "first\n", true).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "first\n");
}
#[test]
fn create_backup_preserves_existing_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
fs::write(&path, "prior\n").unwrap();
create_backup(&path, false).unwrap();
let backups: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.unwrap()
.to_string_lossy()
.contains("cleanlib-backup")
})
.collect();
assert_eq!(backups.len(), 1);
assert_eq!(fs::read_to_string(&backups[0]).unwrap(), "prior\n");
}
#[test]
fn create_backup_skipped_with_force() {
let dir = tempdir().unwrap();
let path = dir.path().join("proxy.conf");
fs::write(&path, "prior\n").unwrap();
create_backup(&path, true).unwrap();
let backups = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.contains("cleanlib-backup")
})
.count();
assert_eq!(backups, 0);
}
#[test]
fn create_backup_creates_parent_directory() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("dir").join("proxy.conf");
create_backup(&path, false).unwrap();
assert!(path.parent().unwrap().is_dir());
}
}