516 lines
15 KiB
Rust
516 lines
15 KiB
Rust
use std::fmt;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use memory::linter::WriteMode;
|
|
use memory::{Linter, WorkspaceLayout};
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LintCliOptions {
|
|
pub workspace: Option<PathBuf>,
|
|
pub json: bool,
|
|
pub warnings_as_errors: bool,
|
|
}
|
|
|
|
impl LintCliOptions {
|
|
fn workspace_root(&self) -> Result<PathBuf, LintCliError> {
|
|
let cwd = std::env::current_dir().map_err(LintCliError::CurrentDir)?;
|
|
Ok(match &self.workspace {
|
|
Some(path) if path.is_absolute() => path.clone(),
|
|
Some(path) => cwd.join(path),
|
|
None => cwd,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum UsageError {
|
|
MissingValue(&'static str),
|
|
UnknownArgument(String),
|
|
UnexpectedArgument(String),
|
|
}
|
|
|
|
impl fmt::Display for UsageError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
|
|
Self::UnknownArgument(arg) => write!(f, "unknown memory lint argument: {arg}"),
|
|
Self::UnexpectedArgument(arg) => write!(f, "unexpected memory lint argument: {arg}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn parse_lint_args(args: &[String]) -> Result<LintCliOptions, UsageError> {
|
|
let mut options = LintCliOptions {
|
|
workspace: None,
|
|
json: false,
|
|
warnings_as_errors: false,
|
|
};
|
|
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--json" => {
|
|
options.json = true;
|
|
i += 1;
|
|
}
|
|
"--warnings-as-errors" => {
|
|
options.warnings_as_errors = true;
|
|
i += 1;
|
|
}
|
|
"--workspace" => {
|
|
let raw = args
|
|
.get(i + 1)
|
|
.ok_or(UsageError::MissingValue("--workspace"))?;
|
|
if raw.starts_with('-') {
|
|
return Err(UsageError::MissingValue("--workspace"));
|
|
}
|
|
options.workspace = Some(PathBuf::from(raw));
|
|
i += 2;
|
|
}
|
|
arg if arg.starts_with("--workspace=") => {
|
|
let value = arg.trim_start_matches("--workspace=");
|
|
if value.is_empty() {
|
|
return Err(UsageError::MissingValue("--workspace"));
|
|
}
|
|
options.workspace = Some(PathBuf::from(value));
|
|
i += 1;
|
|
}
|
|
arg if arg.starts_with('-') => {
|
|
return Err(UsageError::UnknownArgument(arg.to_string()));
|
|
}
|
|
arg => return Err(UsageError::UnexpectedArgument(arg.to_string())),
|
|
}
|
|
}
|
|
|
|
Ok(options)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LintStatus {
|
|
Clean,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum LintCliError {
|
|
CurrentDir(io::Error),
|
|
Io { path: PathBuf, source: io::Error },
|
|
Output(io::Error),
|
|
}
|
|
|
|
impl fmt::Display for LintCliError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::CurrentDir(source) => write!(f, "failed to resolve current directory: {source}"),
|
|
Self::Io { path, source } => write!(f, "io error at {}: {source}", path.display()),
|
|
Self::Output(source) => write!(f, "failed to write lint output: {source}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for LintCliError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::CurrentDir(source) | Self::Io { source, .. } | Self::Output(source) => {
|
|
Some(source)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run(options: &LintCliOptions) -> Result<LintStatus, LintCliError> {
|
|
let stdout = io::stdout();
|
|
run_with_writer(options, stdout.lock())
|
|
}
|
|
|
|
pub fn run_with_writer<W: Write>(
|
|
options: &LintCliOptions,
|
|
mut writer: W,
|
|
) -> Result<LintStatus, LintCliError> {
|
|
let workspace = options.workspace_root()?;
|
|
let report = lint_workspace(&workspace)?;
|
|
|
|
if options.json {
|
|
write_json_report(&mut writer, &report)?;
|
|
} else {
|
|
write_human_report(&mut writer, &report)?;
|
|
}
|
|
|
|
if report.counts.errors > 0 || (options.warnings_as_errors && report.counts.warnings > 0) {
|
|
Ok(LintStatus::Failed)
|
|
} else {
|
|
Ok(LintStatus::Clean)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct WorkspaceLintReport {
|
|
pub workspace: String,
|
|
pub files: Vec<FileLintReport>,
|
|
pub errors: Vec<LintDiagnostic>,
|
|
pub warnings: Vec<LintDiagnostic>,
|
|
pub counts: LintCounts,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct FileLintReport {
|
|
pub path: String,
|
|
pub errors: Vec<String>,
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct LintDiagnostic {
|
|
pub path: String,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, Serialize)]
|
|
pub struct LintCounts {
|
|
pub files: usize,
|
|
pub errors: usize,
|
|
pub warnings: usize,
|
|
pub failing_files: usize,
|
|
}
|
|
|
|
pub fn lint_workspace(workspace: &Path) -> Result<WorkspaceLintReport, LintCliError> {
|
|
let layout = WorkspaceLayout::new(workspace.to_path_buf());
|
|
let linter = Linter::new(layout.clone());
|
|
let mut paths = collect_record_paths(&layout)?;
|
|
paths.sort();
|
|
|
|
let mut files = Vec::with_capacity(paths.len());
|
|
for path in paths {
|
|
let content = std::fs::read_to_string(&path).map_err(|source| LintCliError::Io {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
let lint = linter.lint(&path, &content, WriteMode::Update);
|
|
files.push(FileLintReport {
|
|
path: display_path(workspace, &path),
|
|
errors: lint.errors.iter().map(ToString::to_string).collect(),
|
|
warnings: lint.warnings.iter().map(ToString::to_string).collect(),
|
|
});
|
|
}
|
|
|
|
let counts = count_files(&files);
|
|
let errors = diagnostics_for(&files, DiagnosticKind::Error);
|
|
let warnings = diagnostics_for(&files, DiagnosticKind::Warning);
|
|
Ok(WorkspaceLintReport {
|
|
workspace: workspace.display().to_string(),
|
|
files,
|
|
errors,
|
|
warnings,
|
|
counts,
|
|
})
|
|
}
|
|
|
|
fn collect_record_paths(layout: &WorkspaceLayout) -> Result<Vec<PathBuf>, LintCliError> {
|
|
let mut paths = Vec::new();
|
|
|
|
let summary = layout.summary_path();
|
|
if is_file(&summary)? {
|
|
paths.push(summary);
|
|
}
|
|
|
|
collect_md_files(&layout.decisions_dir(), &mut paths)?;
|
|
collect_md_files(&layout.requests_dir(), &mut paths)?;
|
|
collect_md_files(&layout.knowledge_dir(), &mut paths)?;
|
|
|
|
Ok(paths)
|
|
}
|
|
|
|
fn is_file(path: &Path) -> Result<bool, LintCliError> {
|
|
match std::fs::metadata(path) {
|
|
Ok(meta) => Ok(meta.is_file()),
|
|
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
|
|
Err(source) => Err(LintCliError::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn collect_md_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), LintCliError> {
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(entries) => entries,
|
|
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
|
|
Err(source) => {
|
|
return Err(LintCliError::Io {
|
|
path: dir.to_path_buf(),
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
|
|
for entry in entries {
|
|
let entry = entry.map_err(|source| LintCliError::Io {
|
|
path: dir.to_path_buf(),
|
|
source,
|
|
})?;
|
|
let path = entry.path();
|
|
let file_type = entry.file_type().map_err(|source| LintCliError::Io {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn diagnostics_for(files: &[FileLintReport], kind: DiagnosticKind) -> Vec<LintDiagnostic> {
|
|
files
|
|
.iter()
|
|
.flat_map(|file| {
|
|
let messages = match kind {
|
|
DiagnosticKind::Error => &file.errors,
|
|
DiagnosticKind::Warning => &file.warnings,
|
|
};
|
|
messages.iter().map(|message| LintDiagnostic {
|
|
path: file.path.clone(),
|
|
message: message.clone(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum DiagnosticKind {
|
|
Error,
|
|
Warning,
|
|
}
|
|
|
|
fn count_files(files: &[FileLintReport]) -> LintCounts {
|
|
files.iter().fold(
|
|
LintCounts {
|
|
files: files.len(),
|
|
..LintCounts::default()
|
|
},
|
|
|mut counts, file| {
|
|
counts.errors += file.errors.len();
|
|
counts.warnings += file.warnings.len();
|
|
if !file.errors.is_empty() {
|
|
counts.failing_files += 1;
|
|
}
|
|
counts
|
|
},
|
|
)
|
|
}
|
|
|
|
fn display_path(workspace: &Path, path: &Path) -> String {
|
|
path.strip_prefix(workspace)
|
|
.unwrap_or(path)
|
|
.display()
|
|
.to_string()
|
|
}
|
|
|
|
fn write_human_report<W: Write>(
|
|
writer: &mut W,
|
|
report: &WorkspaceLintReport,
|
|
) -> Result<(), LintCliError> {
|
|
writeln!(writer, "Workspace: {}", report.workspace).map_err(LintCliError::Output)?;
|
|
for file in &report.files {
|
|
let status = if file.errors.is_empty() && file.warnings.is_empty() {
|
|
"ok"
|
|
} else if file.errors.is_empty() {
|
|
"warning"
|
|
} else {
|
|
"error"
|
|
};
|
|
writeln!(writer, "{status}: {}", file.path).map_err(LintCliError::Output)?;
|
|
for error in &file.errors {
|
|
writeln!(writer, " error: {error}").map_err(LintCliError::Output)?;
|
|
}
|
|
for warning in &file.warnings {
|
|
writeln!(writer, " warning: {warning}").map_err(LintCliError::Output)?;
|
|
}
|
|
}
|
|
writeln!(
|
|
writer,
|
|
"Summary: files={} errors={} warnings={} failing_files={}",
|
|
report.counts.files,
|
|
report.counts.errors,
|
|
report.counts.warnings,
|
|
report.counts.failing_files
|
|
)
|
|
.map_err(LintCliError::Output)
|
|
}
|
|
|
|
fn write_json_report<W: Write>(
|
|
writer: &mut W,
|
|
report: &WorkspaceLintReport,
|
|
) -> Result<(), LintCliError> {
|
|
serde_json::to_writer_pretty(&mut *writer, report)
|
|
.map_err(|source| LintCliError::Output(io::Error::other(source)))?;
|
|
writeln!(writer).map_err(LintCliError::Output)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::Value;
|
|
use tempfile::TempDir;
|
|
|
|
fn write(path: &Path, content: &str) {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).unwrap();
|
|
}
|
|
std::fs::write(path, content).unwrap();
|
|
}
|
|
|
|
fn valid_summary() -> &'static str {
|
|
"---\nupdated_at: 2026-05-31T00:00:00Z\n---\nsummary body\n"
|
|
}
|
|
|
|
fn valid_request() -> &'static str {
|
|
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources: []\n---\nrequest body\n"
|
|
}
|
|
|
|
fn warning_request() -> String {
|
|
format!(
|
|
"---\ncreated_at: 2026-05-31T00:00:00Z\nupdated_at: 2026-05-31T00:00:00Z\nsources:\n - segment_id: seg\n range: [0, 1]\n---\n{}\n",
|
|
"x".repeat(1500)
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn parses_lint_options() {
|
|
let args = vec![
|
|
"--workspace".to_string(),
|
|
"/tmp/ws".to_string(),
|
|
"--json".to_string(),
|
|
"--warnings-as-errors".to_string(),
|
|
];
|
|
let parsed = parse_lint_args(&args).unwrap();
|
|
assert_eq!(parsed.workspace, Some(PathBuf::from("/tmp/ws")));
|
|
assert!(parsed.json);
|
|
assert!(parsed.warnings_as_errors);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_lint_usage_errors() {
|
|
assert_eq!(
|
|
parse_lint_args(&["--workspace".to_string()]).unwrap_err(),
|
|
UsageError::MissingValue("--workspace")
|
|
);
|
|
assert_eq!(
|
|
parse_lint_args(&["--workspace".to_string(), "--json".to_string()]).unwrap_err(),
|
|
UsageError::MissingValue("--workspace")
|
|
);
|
|
assert_eq!(
|
|
parse_lint_args(&["--bogus".to_string()]).unwrap_err(),
|
|
UsageError::UnknownArgument("--bogus".to_string())
|
|
);
|
|
assert_eq!(
|
|
parse_lint_args(&["extra".to_string()]).unwrap_err(),
|
|
UsageError::UnexpectedArgument("extra".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lints_only_workspace_memory_and_knowledge_records() {
|
|
let dir = TempDir::new().unwrap();
|
|
let root = dir.path();
|
|
write(&root.join(".insomnia/memory/summary.md"), valid_summary());
|
|
write(
|
|
&root.join(".insomnia/memory/requests/request-one.md"),
|
|
valid_request(),
|
|
);
|
|
write(
|
|
&root.join(".insomnia/memory/_logs/ignored.md"),
|
|
"not frontmatter",
|
|
);
|
|
write(
|
|
&root.join(".insomnia/workflow/ignored.md"),
|
|
"not frontmatter",
|
|
);
|
|
|
|
let report = lint_workspace(root).unwrap();
|
|
assert_eq!(
|
|
report
|
|
.files
|
|
.iter()
|
|
.map(|file| file.path.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
".insomnia/memory/requests/request-one.md",
|
|
".insomnia/memory/summary.md",
|
|
]
|
|
);
|
|
assert_eq!(report.counts.files, 2);
|
|
assert_eq!(report.counts.errors, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_records_count_as_lint_failures() {
|
|
let dir = TempDir::new().unwrap();
|
|
let root = dir.path();
|
|
write(
|
|
&root.join(".insomnia/memory/summary.md"),
|
|
"missing frontmatter",
|
|
);
|
|
|
|
let report = lint_workspace(root).unwrap();
|
|
assert_eq!(report.counts.files, 1);
|
|
assert_eq!(report.counts.errors, 1);
|
|
assert_eq!(report.counts.failing_files, 1);
|
|
assert!(report.files[0].errors[0].contains("frontmatter"));
|
|
}
|
|
|
|
#[test]
|
|
fn warnings_as_errors_changes_status_without_changing_report() {
|
|
let dir = TempDir::new().unwrap();
|
|
let root = dir.path();
|
|
write(
|
|
&root.join(".insomnia/memory/requests/large-record.md"),
|
|
&warning_request(),
|
|
);
|
|
|
|
let mut output = Vec::new();
|
|
let status = run_with_writer(
|
|
&LintCliOptions {
|
|
workspace: Some(root.to_path_buf()),
|
|
json: false,
|
|
warnings_as_errors: true,
|
|
},
|
|
&mut output,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status, LintStatus::Failed);
|
|
let text = String::from_utf8(output).unwrap();
|
|
assert!(text.contains("warnings=1"));
|
|
assert!(text.contains("errors=0"));
|
|
}
|
|
|
|
#[test]
|
|
fn json_output_is_machine_readable() {
|
|
let dir = TempDir::new().unwrap();
|
|
let root = dir.path();
|
|
write(&root.join(".insomnia/memory/summary.md"), valid_summary());
|
|
|
|
let mut output = Vec::new();
|
|
let status = run_with_writer(
|
|
&LintCliOptions {
|
|
workspace: Some(root.to_path_buf()),
|
|
json: true,
|
|
warnings_as_errors: false,
|
|
},
|
|
&mut output,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status, LintStatus::Clean);
|
|
let parsed: Value = serde_json::from_slice(&output).unwrap();
|
|
assert_eq!(parsed["workspace"], root.display().to_string());
|
|
assert_eq!(parsed["counts"]["files"], 1);
|
|
assert_eq!(parsed["files"][0]["path"], ".insomnia/memory/summary.md");
|
|
assert!(parsed["files"][0]["errors"].as_array().unwrap().is_empty());
|
|
}
|
|
}
|