Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
archive: zip

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ needs.resolve-version.outputs.tag }}

Expand Down Expand Up @@ -125,7 +125,7 @@ jobs:
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"

- name: Upload binary artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: cli-${{ matrix.target }}
path: ${{ env.ARCHIVE }}
Expand All @@ -135,10 +135,10 @@ jobs:
needs: [resolve-version, build-cli]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
path: release-artifacts

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-framework.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
name: Package Framework
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Extract version from tag
id: version
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devtrail-cli"
version = "1.3.0"
version = "1.4.0"
edition = "2021"
description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development"
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ pub mod status;
pub mod update;
pub mod update_cli;
pub mod update_framework;
pub mod validate;
171 changes: 171 additions & 0 deletions cli/src/commands/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use anyhow::Result;
use colored::Colorize;
use std::collections::BTreeMap;
use std::path::PathBuf;

use crate::utils;
use crate::validation::{self, Severity, ValidationIssue};

pub fn run(path: &str, fix: bool) -> Result<()> {
let resolved = match utils::resolve_project_root(path) {
Some(r) => r,
None => {
let target = PathBuf::from(path)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(path));
utils::info(&format!(
"DevTrail is not installed in {}",
target.display()
));
utils::info("Run 'devtrail init' to initialize DevTrail in this directory.");
return Ok(());
}
};

if resolved.is_fallback {
utils::info(&format!(
"Using DevTrail installation at repo root: {}",
resolved.path.display()
));
}

let target = resolved.path;
let devtrail_dir = target.join(".devtrail");

// Header
println!();
println!(" {}", "DevTrail Validate".bold().cyan());
println!(" {}", target.display().to_string().dimmed());
println!();

// Run validation
let (result, doc_count) = validation::validate_all(&devtrail_dir);

if doc_count == 0 {
utils::info("No documents found to validate.");
println!(
" {} Create documents with {} or {}",
"→".blue().bold(),
"devtrail-new".cyan(),
"/devtrail-new".cyan()
);
println!();
return Ok(());
}

// Apply fixes if requested
if fix {
apply_fixes(&devtrail_dir);
// Re-validate after fixes
let (result, doc_count) = validation::validate_all(&devtrail_dir);
print_results(&result, doc_count);
return exit_with_code(&result);
}

print_results(&result, doc_count);
exit_with_code(&result)
}

fn apply_fixes(devtrail_dir: &std::path::Path) {
let paths = crate::document::discover_documents(devtrail_dir);
let mut fixed_count = 0;

for path in &paths {
if let Ok(doc) = crate::document::parse_document(path) {
if let Some(new_content) = validation::apply_fixes(&doc) {
if std::fs::write(path, new_content).is_ok() {
println!(
" {} Fixed: {}",
"✓".green().bold(),
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
);
fixed_count += 1;
}
}
}
}

if fixed_count > 0 {
println!();
println!(
" {} {} file(s) fixed automatically",
"→".blue().bold(),
fixed_count
);
println!();
}
}

fn print_results(result: &validation::ValidationResult, doc_count: usize) {
let all_issues: Vec<&ValidationIssue> = result
.errors
.iter()
.chain(result.warnings.iter())
.collect();

if all_issues.is_empty() {
println!(
" {} All {} document(s) passed validation",
"✓".green().bold(),
doc_count
);
println!();
return;
}

// Group by file
let mut by_file: BTreeMap<&PathBuf, Vec<&ValidationIssue>> = BTreeMap::new();
for issue in &all_issues {
by_file.entry(&issue.file).or_default().push(issue);
}

for (file, issues) in &by_file {
let filename = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");

println!(" {}", filename.bold());

for issue in issues {
let severity_label = match issue.severity {
Severity::Error => "error".red().bold(),
Severity::Warning => "warn".yellow().bold(),
};
println!(
" {} [{}] {}",
severity_label, issue.rule, issue.message
);
if let Some(hint) = &issue.fix_hint {
println!(" {} {}", "hint:".dimmed(), hint.dimmed());
}
}
println!();
}

// Summary
let error_count = result.errors.len();
let warning_count = result.warnings.len();

let summary = format!(
" {} error(s), {} warning(s) in {} document(s)",
error_count, warning_count, doc_count
);

if error_count > 0 {
println!("{}", summary.red().bold());
} else {
println!("{}", summary.yellow());
}
println!();
}

fn exit_with_code(result: &validation::ValidationResult) -> Result<()> {
if result.errors.is_empty() {
Ok(())
} else {
std::process::exit(1);
}
}
134 changes: 134 additions & 0 deletions cli/src/complexity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;

/// Report of cyclomatic complexity analysis
#[derive(Debug)]
#[allow(dead_code)]
pub struct ComplexityReport {
pub functions: Vec<FunctionComplexity>,
pub above_threshold: Vec<FunctionComplexity>,
pub threshold: u32,
}

/// Complexity data for a single function
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FunctionComplexity {
pub nloc: u32,
pub ccn: u32,
pub token_count: u32,
pub param_count: u32,
pub length: u32,
pub filename: String,
pub function_name: String,
}

/// Check if lizard is available in PATH
#[allow(dead_code)]
pub fn is_lizard_available() -> bool {
Command::new("lizard")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

/// Analyze cyclomatic complexity using lizard
#[allow(dead_code)]
pub fn analyze_complexity(paths: &[PathBuf], threshold: u32) -> Result<ComplexityReport> {
if !is_lizard_available() {
anyhow::bail!(
"lizard is not installed. Install with: pip install lizard\n\
Without lizard, use qualitative criteria for complexity assessment."
);
}

let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();

let output = Command::new("lizard")
.arg("--csv")
.args(&path_strs)
.output()
.context("Failed to execute lizard")?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("lizard failed: {}", stderr);
}

let stdout = String::from_utf8_lossy(&output.stdout);
let functions = parse_lizard_csv(&stdout)?;

let above_threshold: Vec<FunctionComplexity> = functions
.iter()
.filter(|f| f.ccn > threshold)
.cloned()
.collect();

Ok(ComplexityReport {
functions,
above_threshold,
threshold,
})
}

/// Parse lizard CSV output
#[allow(dead_code)]
fn parse_lizard_csv(csv: &str) -> Result<Vec<FunctionComplexity>> {
let mut functions = Vec::new();

for line in csv.lines() {
// Skip header and empty lines
if line.starts_with("NLOC") || line.trim().is_empty() || line.starts_with('-') {
continue;
}

let fields: Vec<&str> = line.split(',').collect();
if fields.len() < 7 {
continue;
}

let nloc = fields[0].trim().parse::<u32>().unwrap_or(0);
let ccn = fields[1].trim().parse::<u32>().unwrap_or(0);
let token_count = fields[2].trim().parse::<u32>().unwrap_or(0);
let param_count = fields[3].trim().parse::<u32>().unwrap_or(0);
let length = fields[4].trim().parse::<u32>().unwrap_or(0);
let filename = fields[5].trim().trim_matches('"').to_string();
let function_name = fields[6].trim().trim_matches('"').to_string();

functions.push(FunctionComplexity {
nloc,
ccn,
token_count,
param_count,
length,
filename,
function_name,
});
}

Ok(functions)
}

/// Generate JSON output for agent consumption
#[allow(dead_code)]
pub fn report_to_json(report: &ComplexityReport) -> String {
let mut entries = Vec::new();
for f in &report.above_threshold {
entries.push(format!(
r#" {{"function":"{}","file":"{}","ccn":{},"nloc":{},"params":{}}}"#,
f.function_name, f.filename, f.ccn, f.nloc, f.param_count
));
}

format!(
r#"{{"threshold":{},"total_functions":{},"above_threshold":{},"functions":[
{}
]}}"#,
report.threshold,
report.functions.len(),
report.above_threshold.len(),
entries.join(",\n")
)
}
Loading