cargo-rail 0.31.0

Cargo-native Rust workspace engine for affected CI, Rust source-surface analysis, compiler reuse, dependency coherence, exact-SHA releases, and split/sync
//! Generate a sparse cargo-rail configuration.

use crate::error::{RailError, RailResult};
use crate::progress;
use crate::toml::TomlFormatter;
use crate::workspace::WorkspaceContext;
use std::fs;
use std::path::{Path, PathBuf};

/// Generate rail.toml configuration
pub fn run_init(
    ctx: &WorkspaceContext,
    output_path: &str,
    force: bool,
    dry_run: bool,
    json: bool,
    targets: &[String],
    detect_targets: bool,
) -> RailResult<()> {
    run_init_impl(
        ctx.workspace_root(),
        output_path,
        force,
        dry_run,
        json,
        targets,
        detect_targets,
    )
}

/// Detect target triples across config files in the workspace
///
/// Uses fuzzy matching against rustc's canonical target list to find targets
/// regardless of where they're defined (rust-toolchain.toml, .cargo/config.toml,
/// Cross.toml, dist-workspace.toml, .github/workflows/*.yml, etc.).
///
/// Returns sorted list of detected targets.
fn detect_targets(workspace_root: &Path) -> RailResult<Vec<String>> {
    crate::targets::detect_targets(workspace_root)
}

fn build_sparse_config(targets: &[String]) -> String {
    let mut content =
        String::from("# cargo-rail configuration\n# Documentation: https://github.com/loadingalias/cargo-rail\n");
    if !targets.is_empty() {
        content.push_str(
            "\n# Additional supported Cargo target-resolution views; not a compiler/linker execution matrix.\n",
        );
        content.push_str(&format!("targets = {}\n", TomlFormatter::new().array_targets(targets)));
    }
    content
}

/// Check if config file already exists at any location
fn check_existing_config(workspace_root: &Path) -> Option<PathBuf> {
    crate::config::RailConfig::find_config_path(workspace_root)
}

/// Ensure output directory exists
fn ensure_output_dir(output_path: &Path) -> RailResult<()> {
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| RailError::message(format!("failed to create {}: {}", parent.display(), e)))?;
    }
    Ok(())
}

/// Write config file atomically
fn write_config_file(config_path: &Path, content: &str) -> RailResult<()> {
    crate::utils::write_file_atomic(config_path, content.as_bytes())
}

/// Check whether the root manifest defines a Cargo workspace.
fn is_cargo_workspace(workspace_root: &Path) -> bool {
    let cargo_toml = workspace_root.join("Cargo.toml");
    fs::read_to_string(cargo_toml)
        .ok()
        .and_then(|content| content.parse::<toml_edit::DocumentMut>().ok())
        .is_some_and(|manifest| manifest.get("workspace").is_some() || manifest.get("package").is_some())
}

/// Shared implementation for both run_init and run_init_standalone
fn run_init_impl(
    workspace_root: &Path,
    output_path: &str,
    force: bool,
    dry_run: bool,
    json: bool,
    selected_targets: &[String],
    detect_repository_targets: bool,
) -> RailResult<()> {
    let config_path = workspace_root.join(output_path);

    // Warn if not a Cargo workspace (skip warnings in quiet/JSON mode)
    if !is_cargo_workspace(workspace_root) && !dry_run && !json && !crate::output::is_quiet() {
        crate::warn!("no Cargo workspace detected in {}", workspace_root.display());
        eprintln!("         cargo-rail works best with Cargo workspaces\n");
    }

    // Check if target path already exists
    // In --dry-run mode, we just preview, so existence doesn't matter.
    if config_path.exists() && !dry_run {
        if !force {
            return Err(RailError::with_help(
                format!("configuration exists: {}", config_path.display()),
                "use --force to overwrite, or use --dry-run to preview what would be generated",
            ));
        }
        if !json {
            progress!("overwriting: {}", config_path.display());
        }
    } else if let Some(existing) = check_existing_config(workspace_root)
        && existing != config_path
    {
        return Err(RailError::with_help(
            format!("active configuration already exists: {}", existing.display()),
            "edit the active file, or pass its exact path with --output and --force",
        ));
    }

    let mut targets = selected_targets.to_vec();
    if detect_repository_targets {
        targets.extend(detect_targets(workspace_root)?);
    }
    targets.sort();
    targets.dedup();
    let toml_content = build_sparse_config(&targets);

    if dry_run {
        if json {
            let payload = serde_json::json!({
              "command": "init",
              "dry_run": true,
              "config_path": config_path.display().to_string(),
              "targets": targets,
              "content": toml_content
            });
            let output = crate::output::machine_json_envelope("init", "dry_run", "success", 0, payload);
            println!(
                "{}",
                serde_json::to_string_pretty(&output)
                    .map_err(|error| RailError::message(format!("failed to serialize init result: {error}")))?
            );
        } else {
            println!("Destination: {}\n", config_path.display());
            print!("{}", toml_content);
        }
    } else {
        ensure_output_dir(&config_path)?;
        write_config_file(&config_path, &toml_content)?;

        if json {
            let payload = serde_json::json!({
              "command": "init",
              "status": "created",
              "config_path": config_path.display().to_string(),
              "targets": targets
            });
            let output = crate::output::machine_json_envelope("init", "apply", "created", 0, payload);
            println!(
                "{}",
                serde_json::to_string_pretty(&output)
                    .map_err(|error| RailError::message(format!("failed to serialize init result: {error}")))?
            );
        } else {
            println!("Created {}", config_path.display());
            println!("Next: cargo rail config validate");
        }
    }

    Ok(())
}

/// Standalone init (without WorkspaceContext)
pub fn run_init_standalone(
    workspace_root: &Path,
    output_path: &str,
    force: bool,
    dry_run: bool,
    json: bool,
    targets: &[String],
    detect_targets: bool,
) -> RailResult<()> {
    run_init_impl(
        workspace_root,
        output_path,
        force,
        dry_run,
        json,
        targets,
        detect_targets,
    )
}

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

    #[test]
    fn sparse_config_omits_coded_defaults() {
        let toml = build_sparse_config(&[]);
        assert!(toml.contains("cargo-rail configuration"));
        assert!(toml.contains("Documentation:"));
        assert!(!toml.contains("[unify]"));
        assert!(!toml.contains("[release]"));
    }

    #[test]
    fn sparse_config_writes_detected_targets() {
        let toml = build_sparse_config(&["wasm32-unknown-unknown".to_string()]);
        assert!(toml.contains("targets = ["));
        assert!(toml.contains("wasm32-unknown-unknown"));
    }
}