projd 0.1.1

Scan software projects and generate structured reports.
use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use projd_core::{
    BuildSystemKind, DependencyEcosystem, LanguageKind, ProjectKind, ProjectScan, RiskCode,
    RiskSeverity, render_json, render_markdown, scan_path,
};

#[derive(Debug, Parser)]
#[command(name = "projd")]
#[command(version, about = projd_core::describe())]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Scan a local project directory.
    Scan {
        /// Project directory to scan.
        path: PathBuf,

        /// Output format.
        #[arg(short, long)]
        format: Option<OutputFormat>,

        /// Output file. If omitted, rendered content is printed to stdout.
        #[arg(short, long)]
        output: Option<PathBuf>,

        /// Replace the output file if it already exists.
        #[arg(long)]
        overwrite: bool,

        /// Disable Unicode drawing characters in terminal output.
        #[arg(long)]
        no_unicode: bool,
    },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum OutputFormat {
    Terminal,
    Markdown,
    Json,
}

impl OutputFormat {
    fn detect_path(path: &Path) -> Option<Self> {
        let extension = path.extension()?.to_str()?.to_ascii_lowercase();
        match extension.as_str() {
            "md" | "markdown" => Some(Self::Markdown),
            "json" => Some(Self::Json),
            _ => None,
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Some(Command::Scan {
            path,
            format,
            output,
            overwrite,
            no_unicode,
        }) => {
            let format =
                choose_output_format(format, output.as_deref(), io::stdout().is_terminal());
            let scan = scan_path(path)?;
            let rendered = render_scan(
                &scan,
                format,
                TerminalRenderOptions {
                    unicode: !no_unicode,
                    bar_width: 20,
                },
            )?;
            write_or_print(rendered, output, overwrite)
        }
        None => {
            println!("{} {}", projd_core::NAME, projd_core::VERSION);
            println!("{}", projd_core::describe());
            Ok(())
        }
    }
}

fn choose_output_format(
    format: Option<OutputFormat>,
    output: Option<&Path>,
    stdout_is_terminal: bool,
) -> OutputFormat {
    if let Some(format) = format {
        return format;
    }

    if let Some(format) = output.and_then(OutputFormat::detect_path) {
        return format;
    }

    if output.is_none() && stdout_is_terminal {
        OutputFormat::Terminal
    } else {
        OutputFormat::Markdown
    }
}

fn render_scan(
    scan: &ProjectScan,
    format: OutputFormat,
    terminal_options: TerminalRenderOptions,
) -> Result<String> {
    match format {
        OutputFormat::Terminal => Ok(render_terminal(scan, terminal_options)),
        OutputFormat::Markdown => Ok(render_markdown(scan)),
        OutputFormat::Json => render_json(scan).map(|json| format!("{json}\n")),
    }
}

#[derive(Clone, Copy, Debug)]
struct TerminalRenderOptions {
    unicode: bool,
    bar_width: usize,
}

fn render_terminal(scan: &ProjectScan, options: TerminalRenderOptions) -> String {
    let mut output = String::new();
    let separator = if options.unicode { " · " } else { " | " };
    let version = scan
        .identity
        .version
        .as_deref()
        .map(|version| format!(" v{version}"))
        .unwrap_or_default();

    output.push_str("Projd Scan Report\n");
    output.push_str(&format!(
        "{}{}{}{}{}{} files scanned\n",
        scan.identity.name,
        version,
        separator,
        project_kind_label(scan.identity.kind),
        separator,
        scan.files_scanned,
    ));
    output.push_str(&format!("Root: {}\n\n", scan.root.display()));

    output.push_str("Health\n");
    output.push_str(&status_line("README", scan.documentation.has_readme));
    output.push_str(&status_line("License", scan.documentation.has_license));
    output.push_str(&status_line("docs/", scan.documentation.has_docs_dir));
    output.push_str(&status_line("CI", scan.ci.has_github_actions));
    output.push_str(&status_line(
        "Tests",
        scan.tests.test_files > 0 || !scan.tests.commands.is_empty(),
    ));
    output.push_str(&status_line("Lockfiles", lockfiles_ok(scan)));

    output.push_str("\nLanguages\n");
    if scan.languages.is_empty() {
        output.push_str("  none detected\n");
    } else {
        let total_files = scan
            .languages
            .iter()
            .map(|language| language.files)
            .sum::<usize>();
        let mut languages = scan.languages.iter().collect::<Vec<_>>();
        languages.sort_by(|left, right| {
            right
                .files
                .cmp(&left.files)
                .then_with(|| language_label(left.kind).cmp(language_label(right.kind)))
        });

        for language in languages {
            let percent = percentage(language.files, total_files);
            output.push_str(&format!(
                "  {:<12} {} {:>3}% {:>4} file(s)\n",
                language_label(language.kind),
                bar(language.files, total_files, options),
                percent,
                language.files
            ));
        }
    }

    output.push_str("\nBuild Systems\n");
    if scan.build_systems.is_empty() {
        output.push_str("  none detected\n");
    } else {
        for build_system in &scan.build_systems {
            output.push_str(&format!(
                "  {:<12} {}\n",
                build_system_label(build_system.kind),
                build_system.path.display()
            ));
        }
    }

    output.push_str("\nDependencies\n");
    output.push_str(&format!(
        "  {:<12} {}\n",
        "Manifests", scan.dependencies.total_manifests
    ));
    output.push_str(&format!(
        "  {:<12} {}\n",
        "Entries", scan.dependencies.total_dependencies
    ));
    if scan.dependencies.ecosystems.is_empty() {
        output.push_str("  none detected\n");
    } else {
        let total_dependencies = scan.dependencies.total_dependencies;
        for summary in &scan.dependencies.ecosystems {
            let lockfile = if summary.total == 0 {
                "no deps"
            } else if summary.lockfile.is_some() {
                "lockfile OK"
            } else {
                "lockfile missing"
            };
            output.push_str(&format!(
                "  {:<12} {} {:>4} dep(s)  {}\n",
                dependency_label(summary.ecosystem),
                bar(summary.total, total_dependencies, options),
                summary.total,
                lockfile
            ));
        }
    }

    output.push_str("\nTests\n");
    output.push_str(&format!(
        "  {:<12} {}\n",
        "Directories",
        scan.tests.test_directories.len()
    ));
    output.push_str(&format!("  {:<12} {}\n", "Files", scan.tests.test_files));
    if scan.tests.commands.is_empty() {
        output.push_str("  Commands     none detected\n");
    } else {
        for command in &scan.tests.commands {
            output.push_str(&format!("  Command      {}\n", command.command));
        }
    }

    output.push_str("\nRisks\n");
    if scan.risks.findings.is_empty() {
        output.push_str("  none detected\n");
    } else {
        output.push_str(&format!(
            "  {:<12} high {}, medium {}, low {}, info {}\n",
            "Counts", scan.risks.high, scan.risks.medium, scan.risks.low, scan.risks.info
        ));
        for risk in &scan.risks.findings {
            output.push_str(&format!(
                "  {:<8} {:<30} {}\n",
                risk_severity_label(risk.severity),
                risk_code_label(risk.code),
                risk.message
            ));
        }
    }

    output
}

fn status_line(label: &str, ok: bool) -> String {
    let status = if ok { "OK" } else { "Missing" };
    format!("  {label:<12} {status}\n")
}

fn lockfiles_ok(scan: &ProjectScan) -> bool {
    scan.dependencies
        .ecosystems
        .iter()
        .all(|summary| summary.total == 0 || summary.lockfile.is_some())
}

fn bar(value: usize, total: usize, options: TerminalRenderOptions) -> String {
    let width = options.bar_width.max(4);
    let filled = if total == 0 {
        0
    } else {
        ((value * width) + (total / 2)) / total
    }
    .min(width);
    let empty = width - filled;
    let (filled_char, empty_char) = if options.unicode {
        ('', '')
    } else {
        ('#', '.')
    };

    format!(
        "{}{}",
        filled_char.to_string().repeat(filled),
        empty_char.to_string().repeat(empty)
    )
}

fn percentage(value: usize, total: usize) -> usize {
    if total == 0 {
        0
    } else {
        ((value * 100) + (total / 2)) / total
    }
}

fn project_kind_label(kind: ProjectKind) -> &'static str {
    match kind {
        ProjectKind::RustWorkspace => "Rust workspace",
        ProjectKind::RustPackage => "Rust package",
        ProjectKind::NodePackage => "Node package",
        ProjectKind::PythonProject => "Python project",
        ProjectKind::Generic => "Generic project",
    }
}

fn language_label(kind: LanguageKind) -> &'static str {
    match kind {
        LanguageKind::Rust => "Rust",
        LanguageKind::TypeScript => "TypeScript",
        LanguageKind::JavaScript => "JavaScript",
        LanguageKind::Python => "Python",
        LanguageKind::C => "C",
        LanguageKind::Cpp => "C++",
        LanguageKind::Go => "Go",
    }
}

fn build_system_label(kind: BuildSystemKind) -> &'static str {
    match kind {
        BuildSystemKind::Cargo => "Cargo",
        BuildSystemKind::NodePackage => "Node",
        BuildSystemKind::PythonProject => "Python",
        BuildSystemKind::PythonRequirements => "Requirements",
        BuildSystemKind::CMake => "CMake",
        BuildSystemKind::GoModule => "Go module",
    }
}

fn dependency_label(ecosystem: DependencyEcosystem) -> &'static str {
    match ecosystem {
        DependencyEcosystem::Rust => "Rust",
        DependencyEcosystem::Node => "Node",
        DependencyEcosystem::Python => "Python",
    }
}

fn risk_severity_label(severity: RiskSeverity) -> &'static str {
    match severity {
        RiskSeverity::High => "HIGH",
        RiskSeverity::Medium => "MEDIUM",
        RiskSeverity::Low => "LOW",
        RiskSeverity::Info => "INFO",
    }
}

fn risk_code_label(code: RiskCode) -> &'static str {
    match code {
        RiskCode::MissingReadme => "missing-readme",
        RiskCode::MissingLicense => "missing-license",
        RiskCode::MissingCi => "missing-ci",
        RiskCode::NoTestsDetected => "no-tests-detected",
        RiskCode::ManifestWithoutLockfile => "manifest-without-lockfile",
        RiskCode::LargeProjectWithoutIgnoreRules => "large-without-ignore-rules",
    }
}

fn write_or_print(rendered: String, output: Option<PathBuf>, overwrite: bool) -> Result<()> {
    let Some(output) = output else {
        print!("{rendered}");
        return Ok(());
    };

    if output.exists() && !overwrite {
        bail!(
            "refusing to overwrite existing output file `{}`",
            output.display()
        );
    }

    fs::write(&output, rendered)
        .with_context(|| format!("failed to write output file `{}`", output.display()))?;
    Ok(())
}