projd 0.1.0

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

use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use projd_core::{ProjectScan, 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,
    },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum OutputFormat {
    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,
        }) => {
            let format = choose_output_format(format, output.as_deref());
            let scan = scan_path(path)?;
            let rendered = render_scan(&scan, format)?;
            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>) -> OutputFormat {
    format
        .or_else(|| output.and_then(OutputFormat::detect_path))
        .unwrap_or(OutputFormat::Markdown)
}

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

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(())
}