a4-cli 0.12.0

CLI tool for generating TypeScript SDKs from Arete stream specifications
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use colored::Colorize;

use crate::project::installer;
use crate::project::manifest::{AuthoringProgramV1, AuthoringStackV1, ManifestV1};

pub fn init(config_path: &str) -> Result<()> {
    let path = Path::new(config_path);
    if path.exists() {
        anyhow::bail!(
            "Configuration file already exists: {}\nUse a different path or remove the existing file.",
            path.display()
        );
    }
    let root = path.parent().unwrap_or_else(|| Path::new("."));
    println!("{} Initializing Arete project...\n", "".blue().bold());
    let discovered = discover_public_artifacts(root)?;
    println!(
        "{} Found {} StackManifest(s) and {} ProgramSpec(s)",
        "".blue().bold(),
        discovered.stacks.len(),
        discovered.programs.len()
    );

    let project_name = prompt_project_name(root)?;
    let mut manifest = ManifestV1::new(project_name);
    manifest.authoring.stacks = discovered
        .stacks
        .into_iter()
        .map(|(alias, artifact)| {
            let artifact_root = Path::new(&artifact)
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_string_lossy()
                .into_owned();
            (
                alias,
                AuthoringStackV1 {
                    manifest: artifact,
                    artifact_roots: vec![if artifact_root.is_empty() {
                        ".".into()
                    } else {
                        artifact_root
                    }],
                    deployment_name: None,
                },
            )
        })
        .collect();
    manifest.authoring.programs = discovered
        .programs
        .into_iter()
        .map(|(alias, program_spec)| (alias, AuthoringProgramV1 { program_spec }))
        .collect();
    manifest.validate()?;

    let contents = manifest.to_toml_pretty()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, contents)
        .with_context(|| format!("Failed to write project manifest {}", path.display()))?;
    println!("{} Created {}", "".green().bold(), path.display());
    println!(
        "Run {} to validate local artifact closure and output ownership.",
        "a4 config validate".cyan()
    );
    Ok(())
}

pub fn validate(config_path: &str) -> Result<()> {
    println!("{} Validating project manifest...", "".blue().bold());
    let (manifest, plan, lock) = installer::validate_project(config_path, true).context(
        "Failed to validate project manifest. Run `a4 init` to create manifest_version = 1.",
    )?;
    println!("{} Project manifest is valid", "".green().bold());
    println!("  Project: {}", manifest.document.project.name.bold());
    println!("  Dependencies: {}", manifest.dependencies().count());
    println!("  Planned outputs: {}", plan.outputs.len());
    match lock {
        Some(lock) if lock.is_fresh(&manifest.manifest_hash) => {
            println!("  Lock: {}", "fresh".green())
        }
        Some(_) => println!("  Lock: {}", "stale".yellow()),
        None => println!("  Lock: {}", "missing".yellow()),
    }
    Ok(())
}

#[derive(Default)]
struct DiscoveredArtifacts {
    stacks: BTreeMap<String, String>,
    programs: BTreeMap<String, String>,
}

fn discover_public_artifacts(root: &Path) -> Result<DiscoveredArtifacts> {
    let root = fs::canonicalize(root)
        .with_context(|| format!("Failed to resolve project directory {}", root.display()))?;
    let mut files = Vec::new();
    discover_files(&root, &root, 0, &mut files)?;
    files.sort();
    let mut discovered = DiscoveredArtifacts::default();
    for file in files {
        let relative = file
            .strip_prefix(&root)
            .expect("discovery stays beneath root")
            .to_string_lossy()
            .replace(std::path::MAIN_SEPARATOR, "/");
        let name = file
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or_default();
        if let Some(stem) = name.strip_suffix(".stack-manifest.json") {
            insert_discovered(&mut discovered.stacks, portable_alias(stem), relative);
        } else if let Some(stem) = name.strip_suffix(".program-spec.json") {
            insert_discovered(&mut discovered.programs, portable_alias(stem), relative);
        }
    }
    Ok(discovered)
}

fn discover_files(
    root: &Path,
    directory: &Path,
    depth: usize,
    files: &mut Vec<PathBuf>,
) -> Result<()> {
    if depth > 4 {
        return Ok(());
    }
    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(std::fs::DirEntry::path);
    for entry in entries {
        let path = entry.path();
        let metadata = fs::symlink_metadata(&path)?;
        if metadata.file_type().is_symlink() {
            continue;
        }
        if metadata.is_dir() {
            let name = path
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or_default();
            if matches!(name, "target" | "node_modules" | ".git")
                || (name.starts_with('.') && name != ".arete")
            {
                continue;
            }
            discover_files(root, &path, depth + 1, files)?;
        } else if metadata.is_file()
            && path
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| {
                    name.ends_with(".stack-manifest.json") || name.ends_with(".program-spec.json")
                })
        {
            debug_assert!(path.starts_with(root));
            files.push(path);
        }
    }
    Ok(())
}

fn insert_discovered(map: &mut BTreeMap<String, String>, base: String, path: String) {
    let mut alias = base.clone();
    let mut suffix = 2;
    while map.contains_key(&alias) {
        alias = format!("{base}-{suffix}");
        suffix += 1;
    }
    map.insert(alias, path);
}

fn portable_alias(value: &str) -> String {
    let mut alias = value
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>();
    while alias.contains("--") {
        alias = alias.replace("--", "-");
    }
    alias = alias.trim_matches('-').to_string();
    if alias.is_empty() {
        "artifact".into()
    } else {
        alias.chars().take(64).collect()
    }
}

fn prompt_project_name(root: &Path) -> Result<String> {
    let default_name = root
        .file_name()
        .map(|name| name.to_string_lossy().to_string())
        .filter(|name| !name.is_empty())
        .unwrap_or_else(|| "my-project".to_string());
    print!("Project name [{}]: ", default_name.dimmed());
    io::stdout().flush()?;
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let input = input.trim();
    Ok(if input.is_empty() {
        default_name
    } else {
        input.to_string()
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn discovery_never_treats_stack_json_as_an_arete_artifact() {
        let root = std::env::temp_dir().join(format!("arete-init-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(root.join(".arete")).unwrap();
        fs::write(root.join(".arete/Legacy.stack.json"), "{}").unwrap();
        fs::write(root.join(".arete/Exact.stack-manifest.json"), "{}").unwrap();
        let discovered = discover_public_artifacts(&root).unwrap();
        assert_eq!(discovered.stacks.len(), 1);
        assert!(discovered.stacks.contains_key("exact"));
        fs::remove_dir_all(root).unwrap();
    }
}