use std::path::PathBuf;
use std::str::FromStr;
use clap::builder::{PossibleValue, PossibleValuesParser, TypedValueParser};
use clap::{Parser, Subcommand, ValueEnum};
use codelore_lib::cli_api::analysis::{AnalysisName, UnknownAnalysisError};
use codelore_lib::cli_api::constants::{
DEFAULT_FISHER_SIGNIFICANCE, DEFAULT_MAX_CHANGESET_SIZE, DEFAULT_MAX_COUPLING_PCT,
DEFAULT_MIN_COUPLING_PCT, DEFAULT_MIN_REVS, DEFAULT_MIN_SHARED_REVS,
};
pub const ANALYZE_FORMATS: &[(&str, &str)] = &[
("csv", "code-maat-compatible flat tables"),
("json", "stable JSON shape per row type"),
(
"ndjson",
"newline-delimited JSON — one row per line for stream consumers (LSP, `jq -c`, CI pipelines)",
),
("sarif", "SARIF 2.1.0 — surfaces in GitHub Code Scanning"),
("markdown", "GFM tables for `$GITHUB_STEP_SUMMARY`"),
(
"gha",
"GitHub Actions workflow commands — `::error::` / `::warning::` / `::notice::` on stdout, surfaced as inline PR annotations",
),
("html", "self-contained per-analysis HTML report"),
("parquet", "columnar bulk export for analytical pipelines"),
("sqlite", "full DuckDB fact-store dump"),
(
"spa",
"single-file interactive dashboard (opt-in via `spa` feature)",
),
(
"step-summary",
"GFM summary for `$GITHUB_STEP_SUMMARY`; streams to stdout",
),
];
#[must_use]
pub fn analyze_format_names() -> Vec<&'static str> {
ANALYZE_FORMATS.iter().map(|(name, _)| *name).collect()
}
#[derive(Clone)]
pub struct AnalysisNameParser;
impl TypedValueParser for AnalysisNameParser {
type Value = AnalysisName;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let possible = || PossibleValuesParser::new(AnalysisName::all().iter().map(|a| a.as_str()));
let Some(raw) = value.to_str() else {
return Err(possible()
.parse_ref(cmd, arg, value)
.err()
.unwrap_or_else(|| {
clap::Error::raw(clap::error::ErrorKind::InvalidUtf8, "invalid UTF-8\n")
}));
};
match AnalysisName::from_str(raw) {
Ok(name) => Ok(name),
Err(e @ UnknownAnalysisError::IdentityRedirect) => {
Err(clap::Error::raw(clap::error::ErrorKind::ValueValidation, e))
}
Err(UnknownAnalysisError::Unknown(_)) => Err(possible()
.parse_ref(cmd, arg, value)
.err()
.unwrap_or_else(|| {
clap::Error::raw(
clap::error::ErrorKind::InvalidValue,
format!("invalid value '{raw}'\n"),
)
})),
}
}
fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
Some(Box::new(
AnalysisName::all()
.iter()
.map(|a| PossibleValue::new(a.as_str())),
))
}
}
#[derive(ValueEnum, Clone, Debug)]
#[clap(rename_all = "lowercase")]
pub enum CheckFormat {
Text,
Sarif,
}
#[derive(ValueEnum, Clone, Debug)]
#[clap(rename_all = "lowercase")]
pub enum GateFormat {
Text,
Json,
}
#[derive(ValueEnum, Clone, Debug)]
#[clap(rename_all = "lowercase")]
pub enum DiffFormat {
Text,
Json,
Sarif,
Markdown,
}
impl DiffFormat {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
Self::Sarif => "sarif",
Self::Markdown => "markdown",
}
}
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[clap(rename_all = "lowercase")]
pub enum DiffAnalysisKind {
Hotspots,
Coupling,
Clones,
All,
}
impl DiffAnalysisKind {
#[must_use]
pub fn wants_hotspots(self) -> bool {
matches!(self, Self::Hotspots | Self::All)
}
#[must_use]
pub fn wants_coupling(self) -> bool {
matches!(self, Self::Coupling | Self::All)
}
#[must_use]
pub fn wants_clones(self) -> bool {
matches!(self, Self::Clones | Self::All)
}
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[clap(rename_all = "kebab-case")]
pub enum DiffFailOn {
None,
RankEntrant,
ScoreIncrease,
Any,
}
#[derive(Parser, Debug)]
#[command(name = "codelore", version, about = "CodeLore — Behavioral Code Analyzer", long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long = "no-banner", global = true, default_value_t = false)]
pub no_banner: bool,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Analyze(Box<AnalyzeArgs>),
Diff(DiffArgs),
Completions(CompletionsArgs),
Explain(ExplainArgs),
Schema(SchemaArgs),
Profile,
Docs,
Check(CheckArgs),
Gate(GateArgs),
Mcp(McpArgs),
IngestSarif(IngestSarifArgs),
Calibrate(CalibrateArgs),
CalibrateDefects(CalibrateDefectsArgs),
}
#[derive(clap::Args, Debug)]
pub struct McpArgs {
#[arg(short, long, default_value = ".")]
pub repo: std::path::PathBuf,
#[arg(long)]
pub defect_calibration: Option<PathBuf>,
#[arg(long)]
pub allow_foreign_calibration: bool,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(clap::Args, Debug)]
pub struct CheckArgs {
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub thresholds_file: Option<PathBuf>,
#[arg(long)]
pub history: bool,
#[arg(long)]
pub ratchet: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long, value_enum, default_value_t = CheckFormat::Text)]
pub format: CheckFormat,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
#[arg(long = "temp-dir")]
pub temp_dir: Option<PathBuf>,
#[arg(long)]
pub calibration: Option<PathBuf>,
#[arg(long)]
pub defect_calibration: Option<PathBuf>,
#[arg(long)]
pub allow_foreign_calibration: bool,
}
#[derive(clap::Args, Debug)]
pub struct GateArgs {
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub thresholds_file: Option<PathBuf>,
#[arg(long)]
pub quiet: bool,
#[arg(long, value_enum, default_value_t = GateFormat::Text)]
pub format: GateFormat,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
#[arg(long = "temp-dir")]
pub temp_dir: Option<PathBuf>,
#[arg(long)]
pub defect_calibration: Option<PathBuf>,
#[arg(long)]
pub allow_foreign_calibration: bool,
}
#[derive(clap::Args, Debug)]
pub struct CompletionsArgs {
#[arg(value_enum)]
pub shell: clap_complete::Shell,
}
#[derive(clap::Args, Debug)]
pub struct ExplainArgs {
pub topic: Option<String>,
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub llm: bool,
#[arg(long)]
pub llm_refresh: bool,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
#[arg(long)]
pub defect_calibration: Option<PathBuf>,
#[arg(long)]
pub allow_foreign_calibration: bool,
}
#[derive(clap::Args, Debug)]
pub struct SchemaArgs {
pub row_type: Option<String>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(clap::Args, Debug)]
pub struct AnalyzeArgs {
#[arg(
short,
long,
value_parser = AnalysisNameParser,
default_value_t = AnalysisName::Revisions
)]
pub analysis: AnalysisName,
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(
short,
long,
default_value = "csv",
value_parser = PossibleValuesParser::new(analyze_format_names())
)]
pub format: String,
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long, default_value_t = DEFAULT_MIN_REVS)]
pub min_revs: u32,
#[arg(long)]
pub rows: Option<u32>,
#[arg(long, default_value = "head", value_parser = ["head"])]
pub complexity_sample: String,
#[arg(short = 'g', long)]
pub group_file: Option<PathBuf>,
#[arg(long = "team-map-file", short = 'p')]
pub team_map_file: Option<PathBuf>,
#[arg(long = "exclude")]
pub exclude: Vec<String>,
#[arg(long = "include-ignored", default_value_t = false)]
pub include_ignored: bool,
#[arg(long, default_value_t = false)]
pub no_cache: bool,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
#[arg(long = "temp-dir")]
pub temp_dir: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub explain: bool,
#[arg(long, default_value_t = false)]
pub no_canonical_lineage: bool,
#[arg(long, default_value_t = DEFAULT_MIN_SHARED_REVS)]
pub min_shared_revs: u32,
#[arg(long = "min-coupling", default_value_t = DEFAULT_MIN_COUPLING_PCT)]
pub min_coupling_pct: u8,
#[arg(long = "max-coupling", default_value_t = DEFAULT_MAX_COUPLING_PCT)]
pub max_coupling_pct: u8,
#[arg(long, default_value_t = DEFAULT_MAX_CHANGESET_SIZE)]
pub max_changeset_size: u32,
#[arg(long = "age-time-now", value_parser = parse_date)]
pub age_time_now: Option<time::Date>,
#[arg(long, value_parser = parse_date)]
pub after: Option<time::Date>,
#[arg(long, value_parser = parse_date)]
pub before: Option<time::Date>,
#[arg(long)]
pub include_merges: bool,
#[arg(short = 'e', long = "expression-to-match")]
pub message_regex: Option<String>,
#[arg(long)]
pub min_soc: Option<u32>,
#[arg(long = "code-maat-compat", default_value_t = false)]
pub code_maat_compat: bool,
#[arg(long = "fdr-correction", default_value_t = false)]
pub fdr_correction: bool,
#[arg(long = "strict-grouping", default_value_t = false)]
pub strict_grouping: bool,
#[arg(long = "time-bucket", value_enum)]
pub time_bucket: Option<TimeBucketArg>,
#[arg(
long = "departed-threshold-days",
default_value_t = codelore_lib::cli_api::constants::DEFAULT_DEPARTED_THRESHOLD_DAYS
)]
pub departed_threshold_days: u32,
#[arg(
long = "window-days",
default_value_t = codelore_lib::cli_api::constants::DEFAULT_WINDOW_DAYS
)]
pub window_days: u32,
#[arg(long = "knowledge-model", default_value = "commits", value_parser = ["commits", "doe"])]
pub knowledge_model: String,
#[arg(
long = "rework-window-days",
default_value_t = codelore_lib::cli_api::constants::DEFAULT_REWORK_WINDOW_DAYS
)]
pub rework_window_days: u32,
#[arg(
long = "release-tag-glob",
default_value = codelore_lib::cli_api::constants::DEFAULT_RELEASE_TAG_GLOB
)]
pub release_tag_glob: String,
#[arg(long)]
pub target: Option<String>,
#[arg(long)]
pub calibration: Option<PathBuf>,
#[arg(long)]
pub defect_calibration: Option<PathBuf>,
#[arg(long)]
pub allow_foreign_calibration: bool,
}
#[must_use]
pub fn ignored_flag_warnings(args: &AnalyzeArgs, analysis: AnalysisName) -> Vec<String> {
use codelore_lib::cli_api::constants::{
DEFAULT_DEPARTED_THRESHOLD_DAYS, DEFAULT_RELEASE_TAG_GLOB, DEFAULT_REWORK_WINDOW_DAYS,
};
let selected = analysis.as_str();
let mut warnings = Vec::new();
let mut consider = |flag: &str, honored_by: &[&str], is_set: bool| {
if is_set && !honored_by.contains(&selected) {
warnings.push(format!(
"warning: --{flag} was set but is ignored by analysis `{selected}`; \
it is honored only by: {}",
honored_by.join(", ")
));
}
};
consider(
"target",
&["function-xray", "function-coupling"],
args.target.is_some(),
);
consider(
"expression-to-match",
&["messages"],
args.message_regex.is_some(),
);
consider("min-soc", &["soc"], args.min_soc.is_some());
consider(
"knowledge-model",
&["bus-factor"],
args.knowledge_model != "commits",
);
consider(
"departed-threshold-days",
&["knowledge-islands"],
args.departed_threshold_days != DEFAULT_DEPARTED_THRESHOLD_DAYS,
);
consider(
"rework-window-days",
&["delivery-metrics"],
args.rework_window_days != DEFAULT_REWORK_WINDOW_DAYS,
);
consider(
"release-tag-glob",
&["release-cadence"],
args.release_tag_glob != DEFAULT_RELEASE_TAG_GLOB,
);
warnings
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[clap(rename_all = "lowercase")]
pub enum TimeBucketArg {
Day,
Week,
Month,
}
impl From<TimeBucketArg> for codelore_lib::cli_api::options::TimeBucket {
fn from(t: TimeBucketArg) -> Self {
match t {
TimeBucketArg::Day => Self::Day,
TimeBucketArg::Week => Self::Week,
TimeBucketArg::Month => Self::Month,
}
}
}
#[derive(clap::Args, Debug)]
pub struct IngestSarifArgs {
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(required = true)]
pub file: Vec<PathBuf>,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
}
#[derive(clap::Args, Debug)]
pub struct CalibrateArgs {
#[arg(long, required = true)]
pub repos: PathBuf,
#[arg(long, required = true)]
pub output: PathBuf,
#[arg(long)]
pub merge: Option<PathBuf>,
#[arg(long)]
pub vintage: Option<String>,
#[arg(long)]
pub cache_dir: Option<PathBuf>,
}
#[derive(clap::Args, Debug)]
pub struct CalibrateDefectsArgs {
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(long, required = true)]
pub output: PathBuf,
#[arg(long)]
pub vintage: Option<String>,
#[arg(long = "window-days")]
pub window_days: Option<u32>,
#[arg(long = "temp-dir")]
pub temp_dir: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub allow_dirty: bool,
}
fn parse_date(s: &str) -> std::result::Result<time::Date, String> {
use time::format_description::well_known::Iso8601;
time::Date::parse(s, &Iso8601::DEFAULT)
.map_err(|e| format!("invalid date {s:?} (expected YYYY-MM-DD): {e}"))
}
#[derive(clap::Args, Debug)]
pub struct DiffArgs {
pub range: String,
#[arg(short, long, default_value = ".")]
pub repo: PathBuf,
#[arg(short, long, value_enum, default_value_t = DiffAnalysisKind::Hotspots)]
pub analysis: DiffAnalysisKind,
#[arg(long, default_value_t = 10)]
pub top_n: u32,
#[arg(long, default_value_t = 0.05)]
pub score_threshold: f64,
#[arg(long)]
pub base_cache: Option<PathBuf>,
#[arg(short, long, value_enum, default_value_t = DiffFormat::Text)]
pub format: DiffFormat,
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = DiffFailOn::None)]
pub fail_on: DiffFailOn,
#[arg(long, default_value_t = DEFAULT_MIN_REVS)]
pub min_revs: u32,
#[arg(long)]
pub exclude: Vec<String>,
#[arg(long, default_value_t = DEFAULT_MIN_SHARED_REVS)]
pub absence_min_shared: u32,
#[arg(long, default_value_t = DEFAULT_FISHER_SIGNIFICANCE)]
pub absence_fisher_p: f64,
#[arg(long)]
pub thresholds_file: Option<PathBuf>,
#[arg(long)]
pub llm: bool,
#[arg(long)]
pub llm_refresh: bool,
}