cli: move product entrypoint to insomnia

This commit is contained in:
2026-05-31 22:07:52 +09:00
parent 2deb93c7ce
commit 22d974a722
17 changed files with 637 additions and 487 deletions
-6
View File
@@ -4,10 +4,6 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
[[bin]]
name = "insomnia"
path = "src/main.rs"
[dependencies]
client = { workspace = true }
protocol = { workspace = true }
@@ -19,10 +15,8 @@ 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 = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
pulldown-cmark = { version = "0.13.3", default-features = false }
+32 -455
View File
@@ -4,7 +4,6 @@ mod cache;
mod command;
mod input;
mod markdown;
mod memory_lint;
mod multi_pod;
mod picker;
mod pod_list;
@@ -39,7 +38,7 @@ use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use tokio::sync::mpsc;
use client::PodClient;
use client::{PodClient, PodRuntimeCommand};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::picker::PickerOutcome;
@@ -59,8 +58,14 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
})
}
#[derive(Debug)]
enum Mode {
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub mode: LaunchMode,
pub runtime_command: PodRuntimeCommand,
}
#[derive(Debug, Clone)]
pub enum LaunchMode {
Spawn {
profile: Option<String>,
},
@@ -81,218 +86,13 @@ 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),
/// `insomnia pod ...`: run the Pod runtime parser/entrypoint without TUI side effects.
PodRuntime(Vec<String>),
}
#[derive(Debug)]
enum ParseError {
Conflict(&'static str),
InvalidSession(String),
MemoryLint(memory_lint::UsageError),
MissingValue(&'static str),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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"),
}
}
}
fn parse_args() -> Result<Mode, ParseError> {
parse_args_from(std::env::args().skip(1))
}
fn parse_args_from<I, S>(args: I) -> Result<Mode, ParseError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args: Vec<String> = 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));
}
if args.first().map(String::as_str) == Some("pod") {
return Ok(Mode::PodRuntime(args[1..].to_vec()));
}
let mut resume = false;
let mut multi = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut profile: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut socket_seen = false;
let mut positional: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-r" | "--resume" => {
resume = true;
i += 1;
}
"--multi" => {
multi = true;
i += 1;
}
"--session" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--session"))?;
session = Some(
raw.parse::<SegmentId>()
.map_err(|_| ParseError::InvalidSession(raw.clone()))?,
);
i += 2;
}
"--pod" => {
let raw = args.get(i + 1).ok_or(ParseError::MissingValue("--pod"))?;
pod = Some(raw.clone());
i += 2;
}
"--profile" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--profile"))?;
profile = Some(raw.clone());
i += 2;
}
"--socket" => {
socket_seen = true;
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--socket"))?;
socket_override = Some(PathBuf::from(raw));
i += 2;
}
other if positional.is_none() && !other.starts_with('-') => {
positional = Some(other.to_string());
i += 1;
}
_ => {
// Unknown flag or extra positional — keep older
// behaviour of ignoring unknowns rather than aborting.
i += 1;
}
}
}
if multi {
if resume {
return Err(ParseError::Conflict(
"--multi and --resume are mutually exclusive",
));
}
if session.is_some() {
return Err(ParseError::Conflict(
"--multi and --session are mutually exclusive",
));
}
if pod.is_some() {
return Err(ParseError::Conflict(
"--multi and --pod are mutually exclusive",
));
}
if positional.is_some() {
return Err(ParseError::Conflict(
"--multi cannot be used with a positional Pod name",
));
}
if socket_seen {
return Err(ParseError::Conflict(
"--multi and --socket are mutually exclusive",
));
}
if profile.is_some() {
return Err(ParseError::Conflict(
"--multi and --profile are mutually exclusive",
));
}
return Ok(Mode::Multi);
}
if resume && session.is_some() {
return Err(ParseError::Conflict(
"--resume and --session are mutually exclusive",
));
}
if pod.is_some() && session.is_some() {
return Err(ParseError::Conflict(
"--pod and --session are mutually exclusive",
));
}
if pod.is_some() && resume {
return Err(ParseError::Conflict(
"--pod and --resume are mutually exclusive",
));
}
if profile.is_some()
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
{
return Err(ParseError::Conflict(
"--profile can only be used for fresh spawn",
));
}
if let Some(pod_name) = pod {
return Ok(Mode::PodName {
pod_name,
socket_override,
});
}
if let Some(id) = session {
return Ok(Mode::ResumeWithSession(id));
}
if resume {
return Ok(Mode::Resume);
}
if let Some(pod_name) = positional {
return Ok(Mode::PodName {
pod_name,
socket_override,
});
}
Ok(Mode::Spawn { profile })
}
#[tokio::main]
async fn main() -> ExitCode {
let mode = match parse_args() {
Ok(m) => m,
Err(e) => {
eprintln!("insomnia: {e}");
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 Mode::PodRuntime(args) = mode {
return pod::entrypoint::run_cli_from("insomnia pod", args).await;
}
pub async fn launch(options: LaunchOptions) -> ExitCode {
let LaunchOptions {
mode,
runtime_command,
} = options;
if let Err(e) = enable_raw_mode() {
eprintln!("insomnia: failed to enter raw mode: {e}");
@@ -305,16 +105,14 @@ async fn main() -> ExitCode {
}
let result = match mode {
Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::PodName {
LaunchMode::Spawn { profile } => run_spawn(None, profile, runtime_command).await,
LaunchMode::PodName {
pod_name,
socket_override,
} => run_pod_name(pod_name, socket_override).await,
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"),
Mode::PodRuntime(_) => unreachable!("pod runtime returns before terminal setup"),
} => run_pod_name(pod_name, socket_override, runtime_command).await,
LaunchMode::Resume => run_resume(runtime_command).await,
LaunchMode::ResumeWithSession(id) => run_spawn(Some(id), None, runtime_command).await,
LaunchMode::Multi => run_multi(runtime_command).await,
};
// Always restore the terminal first so any pending eprintln below
@@ -349,6 +147,7 @@ async fn main() -> ExitCode {
async fn run_pod_name(
pod_name: String,
socket_override: Option<PathBuf>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
let mut terminal = enter_fullscreen()?;
@@ -356,7 +155,7 @@ async fn run_pod_name(
return Ok(());
}
let ready = match spawn::run_pod_name(pod_name).await? {
let ready = match spawn::run_pod_name(pod_name, runtime_command).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@@ -380,6 +179,7 @@ async fn run_connected_pod(
async fn run_pod_name_nested(
terminal: &mut FullscreenTerminal,
request: multi_pod::OpenPodRequest,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let multi_pod::OpenPodRequest {
pod_name,
@@ -390,16 +190,17 @@ async fn run_pod_name_nested(
return run_connected_pod(terminal, pod_name, client).await;
}
let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name).await?;
let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?;
run_ready_pod(terminal, ready).await
}
async fn spawn_pod_name_from_fullscreen(
terminal: &mut FullscreenTerminal,
pod_name: &str,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
leave_fullscreen(terminal)?;
let outcome = spawn::run_pod_name(pod_name.to_string()).await;
let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await;
enter_fullscreen_existing(terminal)?;
terminal.clear()?;
@@ -463,7 +264,7 @@ async fn connect_live_pod(
.map(|client| (registry_socket, client))
}
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
async fn run_resume(runtime_command: PodRuntimeCommand) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Pod in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room.
let (pod_name, socket_override) = match picker::run().await? {
@@ -473,10 +274,10 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
} => (pod_name, socket_override),
PickerOutcome::Cancelled => return Ok(()),
};
run_pod_name(pod_name, socket_override).await
run_pod_name(pod_name, socket_override, runtime_command).await
}
async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
async fn run_multi(runtime_command: PodRuntimeCommand) -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app().await?;
let mut terminal = enter_fullscreen()?;
@@ -488,7 +289,7 @@ async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
}
multi_pod::MultiPodOutcome::Open(request) => {
let pod_name = request.pod_name.clone();
match run_pod_name_nested(&mut terminal, request).await {
match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await {
Ok(()) => app.finish_open(&pod_name, Ok(())),
Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
app.finish_open(&pod_name, Err(error.as_ref()));
@@ -511,8 +312,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
async fn run_spawn(
resume_from: Option<SegmentId>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile).await? {
let ready = match spawn::run(resume_from, profile, runtime_command).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@@ -1178,231 +980,6 @@ mod tests {
use super::*;
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
#[test]
fn parse_pod_name_mode() {
match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() {
Mode::PodName {
pod_name,
socket_override,
} => {
assert_eq!(pod_name, "agent");
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
}
_ => panic!("expected PodName mode"),
}
}
#[test]
fn parse_positional_name_uses_pod_name_mode() {
match parse_args_from(["agent"]).unwrap() {
Mode::PodName {
pod_name,
socket_override,
} => {
assert_eq!(pod_name, "agent");
assert_eq!(socket_override, None);
}
_ => panic!("expected PodName mode"),
}
}
#[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_pod_subcommand_uses_runtime_mode() {
match parse_args_from(["pod", "--pod", "agent", "--profile", "default"]).unwrap() {
Mode::PodRuntime(args) => assert_eq!(args, ["--pod", "agent", "--profile", "default"]),
_ => panic!("expected PodRuntime mode"),
}
}
#[test]
fn parse_literal_pod_name_still_available_with_flag() {
match parse_args_from(["--pod", "pod"]).unwrap() {
Mode::PodName {
pod_name,
socket_override,
} => {
assert_eq!(pod_name, "pod");
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();
let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err();
assert_eq!(
err.to_string(),
"--pod and --session are mutually exclusive"
);
}
#[test]
fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() {
Mode::Spawn { profile } => {
assert_eq!(profile, Some("/profiles/coder.lua".to_string()));
}
_ => panic!("expected Spawn mode"),
}
}
#[test]
fn parse_profile_rejects_resume_attach_modes() {
let segment_id = session_store::new_segment_id().to_string();
let cases = [
(
vec![
"--profile".to_string(),
"p.lua".to_string(),
"--resume".to_string(),
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.lua".to_string(),
"--session".to_string(),
segment_id,
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.lua".to_string(),
"--socket".to_string(),
"/tmp/insomnia/sock".to_string(),
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.lua".to_string(),
"agent".to_string(),
],
"--profile can only be used for fresh spawn",
),
];
for (args, message) in cases {
let err = parse_args_from(args).unwrap_err();
assert_eq!(err.to_string(), message);
}
}
#[test]
fn parse_multi_mode() {
match parse_args_from(["--multi"]).unwrap() {
Mode::Multi => {}
_ => panic!("expected Multi mode"),
}
}
#[test]
fn parse_multi_conflicts_are_clear() {
let segment_id = session_store::new_segment_id().to_string();
let cases = [
(
vec!["--multi".to_string(), "--resume".to_string()],
"--multi and --resume are mutually exclusive",
),
(
vec!["--multi".to_string(), "--session".to_string(), segment_id],
"--multi and --session are mutually exclusive",
),
(
vec![
"--multi".to_string(),
"--pod".to_string(),
"agent".to_string(),
],
"--multi and --pod are mutually exclusive",
),
(
vec!["--multi".to_string(), "agent".to_string()],
"--multi cannot be used with a positional Pod name",
),
(
vec![
"--multi".to_string(),
"--socket".to_string(),
"/tmp/a.sock".to_string(),
],
"--multi and --socket are mutually exclusive",
),
];
for (args, message) in cases {
let err = parse_args_from(args).unwrap_err();
assert_eq!(err.to_string(), message);
}
}
#[tokio::test]
async fn terminal_event_is_selected_before_ready_pod_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
-515
View File
@@ -1,515 +0,0 @@
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());
}
}
+14 -5
View File
@@ -15,7 +15,7 @@ use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
@@ -76,6 +76,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn run(
resume_from: Option<SegmentId>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut profile_choices = if resume_from.is_some() {
@@ -143,7 +144,7 @@ pub async fn run(
form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form).await {
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
@@ -165,13 +166,16 @@ pub async fn run(
/// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
/// from the default profile.
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
pub async fn run_pod_name(
pod_name: String,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut form = form_for_pod_name(pod_name, defaults);
let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form).await {
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
@@ -360,8 +364,10 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready(
terminal: &mut InlineTerminal,
form: &mut Form,
runtime_command: &PodRuntimeCommand,
) -> Result<SpawnReady, SpawnError> {
let config = SpawnConfig {
runtime_command: runtime_command.clone(),
pod_name: form.name.clone(),
profile: form.selected_profile_selector(),
cwd: form.cwd.clone(),
@@ -687,7 +693,10 @@ description = "Project coder"
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
assert_eq!(choices[0].label, "builtin:default");
assert_eq!(
choices[0].label,
"builtin:default — Bundled default Insomnia coding profile"
);
assert_eq!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder");