use std::io::{BufRead, IsTerminal, Read};
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::{Parser, Subcommand};
mod output;
use output::CheckReportJson;
use scute_config::ScuteConfig;
use scute_core::report::CheckReport;
use scute_core::{
ExecutionError, code_complexity, code_similarity, commit_message, dependency_freshness,
};
use serde::Serialize;
#[derive(Debug, Parser)]
#[command(
name = "scute",
about = "Define the boundaries. Let your code evolve freely within them."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Check {
#[command(subcommand)]
check: Checks,
},
Mcp,
}
#[derive(Debug, Subcommand)]
enum Checks {
List,
CommitMessage {
message: Option<String>,
},
CodeSimilarity {
#[arg(long)]
source_dir: Option<PathBuf>,
files: Vec<PathBuf>,
},
CodeComplexity {
paths: Vec<PathBuf>,
},
DependencyFreshness {
path: Option<String>,
},
}
fn main() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
if err.use_stderr() {
engine_error(&classify_clap_error(&err));
}
err.exit();
}
};
match run(cli) {
Ok(()) => {}
Err(err) => engine_error(&ExecutionError {
code: "unhandled_error".into(),
message: format!("{err}"),
recovery: "please report this issue".into(),
}),
}
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Commands::Mcp => scute_mcp::run().map_err(|e| anyhow::anyhow!(e)),
Commands::Check { check } => {
let project_root = project_root();
let config = ScuteConfig::load(&project_root).unwrap_or_else(|e| invalid_config(&e));
match check {
Checks::List => {
let checks = [
code_similarity::CHECK_NAME,
code_complexity::CHECK_NAME,
commit_message::CHECK_NAME,
dependency_freshness::CHECK_NAME,
];
println!("{}", serde_json::to_string(&checks)?);
Ok(())
}
Checks::CodeComplexity { paths } => {
let paths = resolve_paths(paths, &project_root);
let definition: code_complexity::Definition = config
.definition(code_complexity::CHECK_NAME)
.unwrap_or_else(|e| invalid_config(&e));
let result = code_complexity::check(&paths, &definition);
output(&CheckReport::new(code_complexity::CHECK_NAME, result))
}
Checks::CodeSimilarity { source_dir, files } => run_source_check(
&config,
&project_root,
source_dir,
files,
code_similarity::CHECK_NAME,
code_similarity::check,
),
Checks::CommitMessage { message } => {
let message = resolve_message(message)?;
let definition: commit_message::Definition = config
.definition(commit_message::CHECK_NAME)
.unwrap_or_else(|e| invalid_config(&e));
let result = commit_message::check(&message, &definition);
output(&CheckReport::new(commit_message::CHECK_NAME, result))
}
Checks::DependencyFreshness { path } => {
let target = resolve_target_path(path);
let definition: dependency_freshness::Definition = config
.definition(dependency_freshness::CHECK_NAME)
.unwrap_or_else(|e| invalid_config(&e));
let result = dependency_freshness::check(&target, &definition);
output(&CheckReport::new(dependency_freshness::CHECK_NAME, result))
}
}
}
}
}
fn classify_clap_error(err: &clap::Error) -> ExecutionError {
use clap::error::ErrorKind;
match err.kind() {
ErrorKind::InvalidSubcommand if is_check_level(err) => {
let name = err
.get(clap::error::ContextKind::InvalidSubcommand)
.map(std::string::ToString::to_string)
.unwrap_or_default();
ExecutionError {
code: "unknown_check".into(),
message: format!("unknown check: {name}"),
recovery: format!(
"available checks: {}, {}, {}, {}",
code_similarity::CHECK_NAME,
code_complexity::CHECK_NAME,
commit_message::CHECK_NAME,
dependency_freshness::CHECK_NAME
),
}
}
_ => ExecutionError {
code: "invalid_usage".into(),
message: "missing or invalid arguments".into(),
recovery: "run scute --help for usage".into(),
},
}
}
fn is_check_level(err: &clap::Error) -> bool {
err.get(clap::error::ContextKind::Usage)
.is_some_and(|usage| usage.to_string().contains("scute check"))
}
fn invalid_config(err: &scute_config::ConfigError) -> ! {
engine_error(&ExecutionError {
code: "invalid_config".into(),
message: format!("{err}"),
recovery: "check your .scute.yml syntax".into(),
})
}
#[derive(Serialize)]
struct EngineErrorJson<'a> {
error: &'a ExecutionError,
}
fn engine_error(error: &ExecutionError) -> ! {
let json = EngineErrorJson { error };
println!(
"{}",
serde_json::to_string(&json).expect("engine error serializes")
);
std::process::exit(2);
}
fn output(report: &CheckReport) -> Result<()> {
let json = CheckReportJson::from(report);
println!("{}", serde_json::to_string(&json)?);
if report.has_errors() {
std::process::exit(2);
}
if report.has_failures() {
std::process::exit(1);
}
Ok(())
}
fn project_root() -> PathBuf {
std::env::current_dir().expect("working directory accessible")
}
fn run_source_check<D: Default + serde::de::DeserializeOwned>(
config: &ScuteConfig,
project_root: &Path,
source_dir: Option<PathBuf>,
files: Vec<PathBuf>,
check_name: &str,
execute: impl FnOnce(&Path, &[PathBuf], &D) -> Result<Vec<scute_core::Evaluation>, ExecutionError>,
) -> Result<()> {
let source_dir = source_dir.unwrap_or_else(|| project_root.to_path_buf());
let focus_files = resolve_focus_files(files);
let definition: D = config
.definition(check_name)
.unwrap_or_else(|e| invalid_config(&e));
let result = execute(&source_dir, &focus_files, &definition);
output(&CheckReport::new(check_name, result))
}
fn resolve_target_path(path: Option<String>) -> PathBuf {
path.map_or_else(project_root, PathBuf::from)
}
fn resolve_paths(paths: Vec<PathBuf>, default_dir: &Path) -> Vec<PathBuf> {
let paths = read_from_stdin_if_empty(paths);
scute_core::files::paths_or_default(paths, default_dir)
}
fn resolve_focus_files(files: Vec<PathBuf>) -> Vec<PathBuf> {
read_from_stdin_if_empty(files)
}
fn read_from_stdin_if_empty(paths: Vec<PathBuf>) -> Vec<PathBuf> {
if !paths.is_empty() {
return paths;
}
let stdin = std::io::stdin();
if stdin.is_terminal() {
return Vec::new();
}
stdin
.lock()
.lines()
.map_while(Result::ok)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect()
}
fn resolve_message(arg: Option<String>) -> Result<String> {
if let Some(message) = arg {
return Ok(message);
}
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
return Ok(String::new());
}
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_check_name_classifies_as_unknown_check() {
let err = Cli::try_parse_from(["scute", "check", "does-not-exist"]).unwrap_err();
let error = classify_clap_error(&err);
assert_eq!(error.code, "unknown_check");
}
#[test]
fn missing_check_subcommand_classifies_as_invalid_usage() {
let err = Cli::try_parse_from(["scute", "check"]).unwrap_err();
let error = classify_clap_error(&err);
assert_eq!(error.code, "invalid_usage");
}
#[test]
fn missing_top_level_subcommand_classifies_as_invalid_usage() {
let err = Cli::try_parse_from(["scute", "commit-message", "feat: test"]).unwrap_err();
let error = classify_clap_error(&err);
assert_eq!(error.code, "invalid_usage");
}
}