From 7a717f2d259563df562913e0c3ceb388b094b697 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 31 May 2026 10:03:12 +0900 Subject: [PATCH] cli: add workspace memory lint --- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/main.rs | 90 +++++- crates/tui/src/memory_lint.rs | 515 ++++++++++++++++++++++++++++++++++ 4 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/memory_lint.rs diff --git a/Cargo.lock b/Cargo.lock index c1616f4b..cc9d4c24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3911,6 +3911,7 @@ dependencies = [ "crossterm 0.28.1", "llm-worker", "manifest", + "memory", "pod-registry", "pod-store", "protocol", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 09799309..9eba806e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -19,6 +19,7 @@ unicode-width = "0.2.2" uuid = { workspace = true } toml = { workspace = true } manifest = { workspace = true } +memory = { workspace = true } session-store = { workspace = true } pod-store = { workspace = true } pod-registry = { workspace = true } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d9119729..228b187a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4,6 +4,7 @@ mod cache; mod command; mod input; mod markdown; +mod memory_lint; mod multi_pod; mod picker; mod pod_list; @@ -80,12 +81,15 @@ enum Mode { /// separate from `-r`/`--resume`, which keeps its single-Pod picker /// meaning. Multi, + /// `insomnia memory lint`: headless lint for workspace memory and knowledge files. + MemoryLint(memory_lint::LintCliOptions), } #[derive(Debug)] enum ParseError { Conflict(&'static str), InvalidSession(String), + MemoryLint(memory_lint::UsageError), MissingValue(&'static str), } @@ -94,6 +98,7 @@ impl std::fmt::Display for ParseError { match self { Self::Conflict(message) => write!(f, "{message}"), Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"), + Self::MemoryLint(err) => write!(f, "{err}"), Self::MissingValue(flag) => write!(f, "{flag} requires a value"), } } @@ -109,6 +114,13 @@ where S: Into, { let args: Vec = args.into_iter().map(Into::into).collect(); + if args.first().map(String::as_str) == Some("memory") + && args.get(1).map(String::as_str) == Some("lint") + { + let options = memory_lint::parse_lint_args(&args[2..]).map_err(ParseError::MemoryLint)?; + return Ok(Mode::MemoryLint(options)); + } + let mut resume = false; let mut multi = false; let mut session: Option = None; @@ -255,10 +267,24 @@ async fn main() -> ExitCode { Ok(m) => m, Err(e) => { eprintln!("insomnia: {e}"); - return ExitCode::FAILURE; + return match e { + ParseError::MemoryLint(_) => ExitCode::from(2), + _ => ExitCode::FAILURE, + }; } }; + if let Mode::MemoryLint(ref options) = mode { + return match memory_lint::run(options) { + Ok(memory_lint::LintStatus::Clean) => ExitCode::SUCCESS, + Ok(memory_lint::LintStatus::Failed) => ExitCode::FAILURE, + Err(err) => { + eprintln!("insomnia: {err}"); + ExitCode::from(2) + } + }; + } + if let Err(e) = enable_raw_mode() { eprintln!("insomnia: failed to enter raw mode: {e}"); return ExitCode::FAILURE; @@ -278,6 +304,7 @@ async fn main() -> ExitCode { Mode::Resume => run_resume().await, Mode::ResumeWithSession(id) => run_spawn(Some(id), None).await, Mode::Multi => run_multi().await, + Mode::MemoryLint(_) => unreachable!("memory lint returns before terminal setup"), }; // Always restore the terminal first so any pending eprintln below @@ -1169,6 +1196,67 @@ mod tests { } } + #[test] + fn parse_memory_alone_remains_positional_pod_name() { + match parse_args_from(["memory"]).unwrap() { + Mode::PodName { + pod_name, + socket_override, + } => { + assert_eq!(pod_name, "memory"); + assert_eq!(socket_override, None); + } + _ => panic!("expected PodName mode"), + } + } + + #[test] + fn parse_memory_lint_mode() { + match parse_args_from([ + "memory", + "lint", + "--workspace", + "/tmp/ws", + "--json", + "--warnings-as-errors", + ]) + .unwrap() + { + Mode::MemoryLint(options) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(options.json); + assert!(options.warnings_as_errors); + } + _ => panic!("expected MemoryLint mode"), + } + } + + #[test] + fn parse_memory_lint_rejects_usage_errors() { + let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); + assert_eq!(err.to_string(), "--workspace requires a value"); + } + + #[test] + fn parse_memory_lint_workspace_equals() { + match parse_args_from(["memory", "lint", "--workspace=/tmp/ws"]).unwrap() { + Mode::MemoryLint(options) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(!options.json); + assert!(!options.warnings_as_errors); + } + _ => panic!("expected MemoryLint mode"), + } + } + + #[test] + fn memory_lint_with_other_second_word_remains_positional_pod_name() { + match parse_args_from(["memory", "other"]).unwrap() { + Mode::PodName { pod_name, .. } => assert_eq!(pod_name, "memory"), + _ => panic!("expected PodName mode"), + } + } + #[test] fn parse_rejects_pod_and_session() { let segment_id = session_store::new_segment_id().to_string(); diff --git a/crates/tui/src/memory_lint.rs b/crates/tui/src/memory_lint.rs new file mode 100644 index 00000000..333a0e28 --- /dev/null +++ b/crates/tui/src/memory_lint.rs @@ -0,0 +1,515 @@ +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, + pub json: bool, + pub warnings_as_errors: bool, +} + +impl LintCliOptions { + fn workspace_root(&self) -> Result { + 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 { + 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 { + let stdout = io::stdout(); + run_with_writer(options, stdout.lock()) +} + +pub fn run_with_writer( + options: &LintCliOptions, + mut writer: W, +) -> Result { + 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, + pub errors: Vec, + pub warnings: Vec, + pub counts: LintCounts, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileLintReport { + pub path: String, + pub errors: Vec, + pub warnings: Vec, +} + +#[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 { + 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, 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 { + 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) -> 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 { + 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( + 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( + 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![ + ".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()); + } +}