diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf2f0b..150ca24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable Darc release changes should be summarized here. ## Unreleased +- Add `darc index --rebuild` to recreate the shared SQLite index from every configured project's archived sessions, and point users to it when the local index cannot be opened or migrated. - Show GitHub Release titles as tags, such as `v0.1.6`, while preserving dated changelog headings. - Narrow internal Rust storage APIs so SQLite schema details are no longer exposed outside the storage crate. - Streamline public documentation around JSON query contracts and remove the internal backlog from docs. diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index fe9abff..40c1622 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -118,8 +118,8 @@ pub(crate) enum Commands { )] Sync(SyncArgs), #[command( - about = "Index archived sessions for the active project into SQLite", - long_about = "Index archived sessions for the active project into the shared Darc SQLite database.\n\nRun this after `darc sync` when you want to rebuild searchable/queryable state without copying new archive files.", + about = "Index archived sessions into SQLite", + long_about = "Index archived sessions into the shared Darc SQLite database.\n\nWithout `--rebuild`, this indexes the active project. Run it after `darc sync` when you want to refresh searchable/queryable state without copying new archive files.\n\nUse `--rebuild` only when Darc reports that the SQLite index cannot be opened or migrated. Rebuild deletes the shared local index cache, then recreates it from every configured project's archived sessions.", after_help = index_after_help() )] Index(IndexArgs), @@ -474,10 +474,17 @@ pub(crate) struct IndexArgs { long = "provider", value_enum, help_heading = "Selection", - help = "Limit indexing to the selected providers" + help = "Limit non-rebuild indexing to the selected providers" )] pub(crate) provider: Vec, + #[arg( + long, + help_heading = "Mode", + help = "Delete the shared SQLite index and rebuild it from every configured project's archived sessions" + )] + pub(crate) rebuild: bool, + #[arg( long, default_value_os_t = default_root_path(), diff --git a/crates/cli/src/args/help.rs b/crates/cli/src/args/help.rs index 2d22d63..4bfe45d 100644 --- a/crates/cli/src/args/help.rs +++ b/crates/cli/src/args/help.rs @@ -56,7 +56,10 @@ pub(crate) fn sync_after_help() -> String { /// Returns index command examples. pub(crate) fn index_after_help() -> String { - styled_help_section("Examples", " darc index\n darc index --provider claude") + styled_help_section( + "Examples", + " darc index\n darc index --provider claude\n darc index --rebuild", + ) } /// Returns canonical search command examples. diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index a2d3431..ca0285a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(target_os = "windows")] +compile_error!("Darc CLI does not support Windows."); + mod agent_help; mod args; mod output; @@ -26,7 +29,7 @@ use clap::{FromArgMatches, error::ErrorKind}; use darc_core::query::SearchEvidenceField; #[cfg(test)] use output::*; -use output::{format_json_clap_error, format_query_error}; +use output::{HumanStyle, format_json_clap_error, format_query_error, format_standard_error}; use project::{run_init, run_link, run_project, run_remove, run_rename_from}; #[cfg(test)] use query_commands::*; @@ -133,7 +136,7 @@ fn standard_exit(result: Result<()>) -> i32 { match result { Ok(()) => 0, Err(error) => { - eprintln!("error: {error:#}"); + eprintln!("{}", format_standard_error(&error, HumanStyle::stderr())); 1 } } diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index c1518d1..2cab0f5 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use darc_core::query::{ QueryProtocolError, SearchMode, SearchSnippetMatcher, SearchTurnsQueryData, TurnsView, }; +use darc_core::{IndexDatabaseRebuildRecommendation, index_rebuild_command}; use darc_paths::current_utc_timestamp; use serde::Serialize; use serde_json::{Value as JsonValue, json}; @@ -533,7 +534,7 @@ pub(crate) fn format_query_error(error: &anyhow::Error) -> String { generated_at: current_utc_timestamp(), darc_version: env!("CARGO_PKG_VERSION"), error: QueryErrorData { - message: error.to_string(), + message: format_query_error_message(error), code: structured .map(QueryProtocolError::code) .or_else(|| read_validation.map(|error| error.code)) @@ -550,6 +551,78 @@ pub(crate) fn format_query_error(error: &anyhow::Error) -> String { }) } +/// Returns one query error message with actionable index rebuild guidance when available. +fn format_query_error_message(error: &anyhow::Error) -> String { + if let Some(rebuild) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return format!( + "local SQLite index at {} needs to be rebuilt; run `{}` to rebuild the shared index cache from archived sessions", + rebuild.path().display(), + index_rebuild_command(rebuild.path()) + ); + } + + error.to_string() +} + +/// Formats one standard human-facing command error. +pub(crate) fn format_standard_error(error: &anyhow::Error, style: HumanStyle) -> String { + if let Some(rebuild) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return format_index_rebuild_error(error, rebuild, style); + } + + format!("error: {error:#}") +} + +/// Formats the actionable recovery message for an unusable SQLite index. +fn format_index_rebuild_error( + error: &anyhow::Error, + rebuild: &IndexDatabaseRebuildRecommendation, + style: HumanStyle, +) -> String { + let mut lines = vec![ + "error: local SQLite index needs to be rebuilt".to_owned(), + format!(" Index DB: {}", style.path(rebuild.path().display())), + format!( + " Run: {}", + style.bold(index_rebuild_command(rebuild.path())) + ), + " This deletes only the shared SQLite index cache, then rebuilds it from every configured project's archived sessions.".to_owned(), + " Archived sessions are not deleted.".to_owned(), + ]; + if let Some(cause) = index_rebuild_source_detail(error) { + lines.push(format!(" Cause: {cause}")); + } + lines.join("\n") +} + +/// Returns source details after the rebuild recommendation in an error chain. +fn index_rebuild_source_detail(error: &anyhow::Error) -> Option { + let mut saw_rebuild = false; + let mut details = Vec::new(); + for cause in error.chain() { + if saw_rebuild { + details.push(cause.to_string()); + } else if cause + .downcast_ref::() + .is_some() + { + saw_rebuild = true; + } + } + + if details.is_empty() { + None + } else { + Some(details.join(": ")) + } +} + /// Returns one machine-readable JSON error envelope string for JSON parse failures. pub(crate) fn format_json_clap_error(error: &clap::Error, args: &[OsString]) -> String { let message = normalize_json_clap_error_message(error.to_string().trim_end().to_owned(), args); diff --git a/crates/cli/src/sync_index.rs b/crates/cli/src/sync_index.rs index 20bd00a..d824076 100644 --- a/crates/cli/src/sync_index.rs +++ b/crates/cli/src/sync_index.rs @@ -1,13 +1,20 @@ -use std::path::Path; +use std::{ + env, + io::{self, IsTerminal}, + path::{Path, PathBuf}, +}; -use anyhow::Result; +use anyhow::{Result, bail}; use darc_core::{ - IndexOptions, IndexReport, SkippedRollout, SourceKind, SyncOptions, SyncReport, execute_sync, - index_project_sessions, prepare_sync, + IndexOptions, IndexReport, SkippedRollout, SourceKind, SyncOptions, SyncReport, + WorkspaceIndexReport, execute_sync, index_project_sessions, prepare_sync, + rebuild_workspace_index, }; use crate::args::{IndexArgs, ProviderArg, SyncArgs}; -use crate::output::{HumanStyle, print_field, print_line, print_section, print_warning}; +use crate::output::{ + HumanStyle, print_field, print_line, print_project_warning, print_section, print_warning, +}; /// Prepares and optionally executes the project-scoped sync workflow. pub(crate) fn run_sync(args: SyncArgs) -> Result<()> { @@ -183,12 +190,25 @@ pub(crate) fn add_init_hint_for_unconfigured_project(error: anyhow::Error) -> an /// Indexes archived sessions for the active project into SQLite. pub(crate) fn run_index(args: IndexArgs) -> Result<()> { - let report = index_project_sessions( - Some(args.root), - IndexOptions { - provider_filter: args.provider.into_iter().map(ProviderArg::into).collect(), - }, - )?; + let IndexArgs { + provider, + rebuild, + root, + } = args; + if rebuild && !provider.is_empty() { + bail!( + "`darc index --rebuild` rebuilds all providers; remove `--provider` and run it again" + ); + } + + let _lock = acquire_index_lock_if_configured(&root)?; + if rebuild { + return run_index_rebuild(root); + } + let options = IndexOptions { + provider_filter: provider.into_iter().map(ProviderArg::into).collect(), + }; + let report = index_project_sessions(Some(root), options)?; let style = HumanStyle::stdout(); for skipped in &report.skipped_rollouts { @@ -216,6 +236,94 @@ pub(crate) fn run_index(args: IndexArgs) -> Result<()> { Ok(()) } +/// Acquires the shared index writer lock when this Darc root is configured. +fn acquire_index_lock_if_configured(root: &Path) -> Result> { + if root.join("config.toml").exists() { + crate::refresh::acquire_refresh_lock(root).map(Some) + } else { + Ok(None) + } +} + +/// Rebuilds the shared workspace index from every configured project. +fn run_index_rebuild(root: PathBuf) -> Result<()> { + print_index_rebuild_started(); + let report = rebuild_workspace_index(Some(root))?; + let style = HumanStyle::stdout(); + + for project in &report.projects { + for skipped in &project.skipped_rollouts { + print_project_warning(&project.project_name, format_skipped_rollout(skipped)); + } + } + + print_workspace_rebuild_summary(style, &report); + println!(); + print_section(style, "Status"); + let status = if report.skipped_rollout_count() == 0 { + style.ok("indexed") + } else { + style.warn("indexed with skipped rollouts") + }; + print_field(style, 2, "Overall", status); + + Ok(()) +} + +/// Prints one short rebuild progress line when stderr is interactive. +fn print_index_rebuild_started() { + let term = env::var("TERM").ok(); + if io::stderr().is_terminal() && term.as_deref() != Some("dumb") { + eprintln!("{}", format_index_rebuild_started(HumanStyle::stderr())); + } +} + +/// Formats the initial rebuild progress line. +fn format_index_rebuild_started(style: HumanStyle) -> String { + format!( + "Rebuilding {} from archived sessions...", + style.bold("shared index") + ) +} + +/// Prints one workspace-wide rebuild summary. +fn print_workspace_rebuild_summary(style: HumanStyle, report: &WorkspaceIndexReport) { + print_section(style, "Index Rebuild"); + print_field(style, 2, "Root", style.path(report.root.display())); + print_field(style, 2, "Providers", format_sources(&report.providers)); + print_field( + style, + 2, + "Index DB", + style.path(report.index_db_path.display()), + ); + print_field( + style, + 2, + "Projects indexed", + style.count(report.projects.len()), + ); + print_field( + style, + 2, + "Sessions currently indexed", + style.count(report.sessions_currently_indexed()), + ); + print_field( + style, + 2, + "Turns currently indexed", + style.count(report.turns_currently_indexed()), + ); + let skipped = report.skipped_rollout_count(); + let skipped = if skipped == 0 { + style.ok(skipped) + } else { + style.warn(skipped) + }; + print_field(style, 2, "Skipped rollout files", skipped); +} + /// Formats a source list for compact CLI output. pub(crate) fn format_sources(sources: &[SourceKind]) -> String { sources @@ -251,3 +359,17 @@ pub(crate) fn format_skipped_rollout(skipped: &SkippedRollout) -> String { ) } } + +#[cfg(test)] +mod tests { + use super::{HumanStyle, format_index_rebuild_started}; + + #[test] + fn index_rebuild_started_message_is_short_and_styled() { + let plain = format_index_rebuild_started(HumanStyle { enabled: false }); + assert_eq!(plain, "Rebuilding shared index from archived sessions..."); + + let styled = format_index_rebuild_started(HumanStyle { enabled: true }); + assert!(styled.contains("\x1b[1mshared index\x1b[0m")); + } +} diff --git a/crates/cli/src/tests.rs b/crates/cli/src/tests.rs index c4da674..c12d4ff 100644 --- a/crates/cli/src/tests.rs +++ b/crates/cli/src/tests.rs @@ -11,6 +11,7 @@ use darc_core::{ IndexReport, RefreshAllBestEffortReport, RefreshProgress, RefreshProjectAttempt, RefreshProjectFailure, RefreshReport, SourceKind, SyncReport, config::{ClaudeSourceConfig, CodexSourceConfig, SharedConfig, SourcesConfig, WatchConfig}, + index_rebuild_command, }; use darc_rollout_audit::claude::{ ClaudeSchemaAuditFailure, ClaudeSchemaAuditReport, ClaudeSchemaDrift, diff --git a/crates/cli/src/tests/cli.rs b/crates/cli/src/tests/cli.rs index 7d9fd57..d7b6cb6 100644 --- a/crates/cli/src/tests/cli.rs +++ b/crates/cli/src/tests/cli.rs @@ -138,6 +138,84 @@ fn rendered_help_is_plain_but_carries_ansi_styles() { assert_eq!(strip_ansi_text(&ansi), plain); } +#[test] +fn standard_error_formats_index_rebuild_guidance() -> Result<()> { + let root = unique_test_dir("cli-rebuild-error"); + let index_path = root.join("index.sqlite"); + write_file(&index_path, "not a sqlite database")?; + let error = darc_store::open_index_database(&index_path).expect_err("index should fail"); + let command = index_rebuild_command(&index_path); + + let plain = format_standard_error(&error, HumanStyle { enabled: false }); + assert_contains_in_order( + &plain, + &[ + "error: local SQLite index needs to be rebuilt", + "Index DB:", + &format!("Run: {command}"), + "This deletes only the shared SQLite index cache", + "Archived sessions are not deleted.", + "Cause:", + ], + ); + assert!(!plain.contains("\x1b[")); + + let styled = format_standard_error(&error, HumanStyle { enabled: true }); + assert!(styled.contains(&format!("\x1b[1m{command}\x1b[0m"))); + assert_eq!(strip_ansi_text(&styled), plain); + + Ok(()) +} + +#[test] +fn index_rebuild_command_preserves_custom_root() { + let path = PathBuf::from("/tmp/darc root/with'quote/index.sqlite"); + + assert_eq!( + index_rebuild_command(&path), + "darc index --rebuild --root '/tmp/darc root/with'\\''quote'" + ); +} + +#[test] +fn query_error_formats_custom_root_index_rebuild_guidance() -> Result<()> { + let root = unique_test_dir("query-rebuild-error"); + let index_path = root.join("index.sqlite"); + write_file(&index_path, "not a sqlite database")?; + let error = darc_store::open_existing_index_database(&index_path) + .expect_err("query index open should fail"); + let command = index_rebuild_command(&index_path); + + let payload: Value = serde_json::from_str(&format_query_error(&error))?; + let message = payload["error"]["message"] + .as_str() + .expect("message should be a string"); + + assert!(message.contains(&format!("run `{command}`"))); + assert!(message.contains(&index_path.display().to_string())); + + Ok(()) +} + +#[test] +fn index_rebuild_respects_workspace_refresh_lock() -> Result<()> { + let root = unique_test_dir("index-rebuild-lock"); + write_file(&root.join("config.toml"), "projects = []\n")?; + let _lock = acquire_refresh_lock(&root)?; + + let error = run_index(IndexArgs { + provider: Vec::new(), + rebuild: true, + root, + }) + .expect_err("index rebuild should not run while the workspace lock is held"); + let message = format!("{error:#}"); + + assert!(message.contains("another Darc refresh is already running")); + assert!(message.contains("refresh.lock")); + Ok(()) +} + #[test] fn launchctl_failure_message_structures_bootstrap_errors() { let args = vec![ diff --git a/crates/cli/src/tests/command_queries.rs b/crates/cli/src/tests/command_queries.rs index 96eaa50..c986ed9 100644 --- a/crates/cli/src/tests/command_queries.rs +++ b/crates/cli/src/tests/command_queries.rs @@ -28,6 +28,15 @@ fn human_command_help_groups_options() { assert!(sync_help.contains("Preview pending copies without writing files")); assert!(sync_help.contains("darc sync --dry-run")); + let index_help = help_for_command_path(&["index"]); + assert_contains_in_order(&index_help, &["Selection:", "Mode:", "Workspace:"]); + assert!( + index_help.contains( + "Delete the shared SQLite index and rebuild it from every configured project's archived sessions" + ) + ); + assert!(index_help.contains("darc index --rebuild")); + let refresh_help = help_for_command_path(&["refresh"]); assert_contains_in_order( &refresh_help, @@ -42,10 +51,39 @@ fn index_command_accepts_provider_filters() { let cli = Cli::try_parse_from(["darc", "index", "--provider", "claude"]).unwrap(); assert!(matches!( cli.command, - Commands::Index(super::IndexArgs { provider, .. }) if provider.len() == 1 + Commands::Index(super::IndexArgs { + provider, + rebuild: false, + .. + }) if provider.len() == 1 + )); + + let rebuild = Cli::try_parse_from(["darc", "index", "--rebuild"]).unwrap(); + assert!(matches!( + rebuild.command, + Commands::Index(super::IndexArgs { + provider, + rebuild: true, + .. + }) if provider.is_empty() )); } +#[test] +fn index_rebuild_rejects_provider_filters() { + let error = run_index(super::IndexArgs { + provider: vec![super::ProviderArg::Codex], + rebuild: true, + root: unique_test_dir("index-rebuild-provider-filter"), + }) + .expect_err("rebuild should reject provider filters"); + + assert!( + format!("{error:#}") + .contains("`darc index --rebuild` rebuilds all providers; remove `--provider`") + ); +} + #[test] fn sync_command_accepts_provider_filters() { let cli = Cli::try_parse_from(["darc", "sync", "--provider", "claude"]).unwrap(); diff --git a/crates/core/src/index.rs b/crates/core/src/index.rs index 6c528e1..f387295 100644 --- a/crates/core/src/index.rs +++ b/crates/core/src/index.rs @@ -1,18 +1,21 @@ use std::{ collections::BTreeSet, env, + ffi::OsString, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; pub use darc_index::{IndexReport, SkippedCodexRollout, SkippedRollout}; use darc_index::{ProjectIndexRequest, index_project_archived_sessions}; use darc_paths::SourceKind; -use darc_store::INDEX_DB_FILE_NAME; +use darc_store::{INDEX_DB_FILE_NAME, remove_index_database, replace_index_database}; use crate::{ active_project::{ActiveProject, load_active_project}, default_root_path, + project::registered_projects, }; /// Collects optional provider filters for the indexing workflow. @@ -21,6 +24,41 @@ pub struct IndexOptions { pub provider_filter: Vec, } +/// Reports a workspace-wide index rebuild. +#[derive(Debug, Clone)] +pub struct WorkspaceIndexReport { + pub root: PathBuf, + pub index_db_path: PathBuf, + pub providers: Vec, + pub projects: Vec, +} + +impl WorkspaceIndexReport { + /// Returns the total currently indexed session count across rebuilt projects. + pub fn sessions_currently_indexed(&self) -> usize { + self.projects + .iter() + .map(|project| project.sessions_currently_indexed) + .sum() + } + + /// Returns the total currently indexed turn count across rebuilt projects. + pub fn turns_currently_indexed(&self) -> usize { + self.projects + .iter() + .map(|project| project.turns_currently_indexed) + .sum() + } + + /// Returns the total number of skipped rollout files across rebuilt projects. + pub fn skipped_rollout_count(&self) -> usize { + self.projects + .iter() + .map(|project| project.skipped_rollouts.len()) + .sum() + } +} + /// Indexes archived sessions for the active project into SQLite. pub fn index_project_sessions(root: Option, options: IndexOptions) -> Result { let current_dir = @@ -42,6 +80,85 @@ pub fn index_project_codex_turns(root: Option) -> Result { ) } +/// Rebuilds the shared SQLite index from every configured project's archived sessions. +pub fn rebuild_workspace_index(root: Option) -> Result { + let root = root.unwrap_or_else(default_root_path); + let providers = selected_index_providers(&[]); + let projects = registered_projects(&root)?; + if projects.is_empty() { + anyhow::bail!("no configured darc projects found under {}", root.display()); + } + + let index_db_path = root.join(INDEX_DB_FILE_NAME); + let temp_index_db_path = rebuild_index_database_path(&index_db_path)?; + remove_index_database(&temp_index_db_path)?; + + let rebuild_result = rebuild_workspace_index_into(&projects, &providers, &temp_index_db_path); + let mut reports = match rebuild_result { + Ok(reports) => reports, + Err(error) => { + let _cleanup_result = remove_index_database(&temp_index_db_path); + return Err(error); + } + }; + + replace_index_database(&temp_index_db_path, &index_db_path)?; + for report in &mut reports { + report.index_db_path.clone_from(&index_db_path); + } + + Ok(WorkspaceIndexReport { + root, + index_db_path, + providers, + projects: reports, + }) +} + +/// Returns the CLI rebuild command for the Darc root that owns one index database. +pub fn index_rebuild_command(index_db_path: &Path) -> String { + let Some(root) = index_db_path.parent() else { + return "darc index --rebuild".to_owned(); + }; + if root == default_root_path() { + "darc index --rebuild".to_owned() + } else { + format!( + "darc index --rebuild --root {}", + shell_quote(&root.display().to_string()) + ) + } +} + +/// Returns one POSIX-shell-safe single-quoted string. +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Rebuilds every configured project into one target SQLite index path. +fn rebuild_workspace_index_into( + projects: &[crate::config::ProjectConfig], + providers: &[SourceKind], + index_db_path: &Path, +) -> Result> { + let mut reports = Vec::with_capacity(projects.len()); + for project in projects { + let request = ProjectIndexRequest { + project_id: project.id.clone(), + project_name: project.name.clone(), + project_root: project.local_path.clone(), + sessions_root: project.sessions_root.clone(), + index_db_path: index_db_path.to_path_buf(), + }; + reports.push( + index_project_archived_sessions(&request, providers) + .with_context(|| format!("failed to index project `{}`", project.name))?, + ); + } + + Ok(reports) +} + /// Indexes archived provider rollouts for one explicit current directory and darc root. pub(crate) fn index_project_sessions_from( current_dir: &Path, @@ -81,3 +198,258 @@ pub(crate) fn selected_index_providers(filter: &[SourceKind]) -> Vec .into_iter() .collect() } + +/// Returns a unique temporary SQLite path next to the shared index database. +fn rebuild_index_database_path(index_db_path: &Path) -> Result { + let file_name = index_db_path.file_name().with_context(|| { + format!( + "index database path {} is missing a filename", + index_db_path.display() + ) + })?; + let mut temp_name = OsString::from(file_name); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + temp_name.push(format!(".rebuild-{}-{nanos}.tmp", std::process::id())); + Ok(index_db_path.with_file_name(temp_name)) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use anyhow::Result; + use rusqlite::Connection; + + use super::rebuild_workspace_index; + use crate::{ + config::{ProjectConfig, SharedConfig, SourcesConfig}, + constants::CONFIG_FILE_NAME, + }; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Builds one unique temporary directory for index workflow tests. + fn unique_test_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let counter = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "darc-index-{label}-{}-{nanos}-{counter}", + std::process::id() + )) + } + + /// Writes one UTF-8 file after creating its parent directory. + fn write_file(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + Ok(()) + } + + /// Writes one minimal archived Codex rollout fixture. + fn write_archived_codex_rollout( + sessions_root: &Path, + project_root: &Path, + session_id: &str, + user_message: &str, + assistant_reply: &str, + ) -> Result<()> { + write_file( + &sessions_root + .join("codex") + .join(format!("rollout-2026-04-01T10-00-00-{session_id}.jsonl")), + &format!( + concat!( + "{{\"timestamp\":\"2026-04-01T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{cwd}\",\"cli_version\":\"0.118.0\"}}}}\n", + "{{\"timestamp\":\"2026-04-01T10:00:01Z\",\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_started\",\"turn_id\":\"turn-1\"}}}}\n", + "{{\"timestamp\":\"2026-04-01T10:00:02Z\",\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"{user_message}\"}}}}\n", + "{{\"timestamp\":\"2026-04-01T10:00:03Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"phase\":\"final_answer\",\"content\":[{{\"type\":\"output_text\",\"text\":\"{assistant_reply}\"}}]}}}}\n" + ), + session_id = session_id, + cwd = project_root.display(), + user_message = user_message, + assistant_reply = assistant_reply, + ), + ) + } + + #[test] + fn rebuild_workspace_index_recreates_shared_index_for_all_projects() -> Result<()> { + let root = unique_test_dir("workspace-rebuild"); + let first_project_root = root.join("repo-one"); + let second_project_root = root.join("repo-two"); + let first_sessions_root = root.join("projects/repo-one-123/sessions"); + let second_sessions_root = root.join("projects/repo-two-456/sessions"); + fs::create_dir_all(&first_project_root)?; + fs::create_dir_all(&second_project_root)?; + write_archived_codex_rollout( + &first_sessions_root, + &first_project_root, + "22222222-2222-4222-8222-22222222223f", + "Index first project", + "First indexed", + )?; + write_archived_codex_rollout( + &second_sessions_root, + &second_project_root, + "33333333-3333-4333-8333-33333333333f", + "Index second project", + "Second indexed", + )?; + let config = SharedConfig::new( + root.clone(), + vec![ + ProjectConfig { + id: "repo-one-123".into(), + name: "repo-one".into(), + local_path: first_project_root, + git_upstream: None, + sessions_root: first_sessions_root, + known_paths: Vec::new(), + }, + ProjectConfig { + id: "repo-two-456".into(), + name: "repo-two".into(), + local_path: second_project_root, + git_upstream: None, + sessions_root: second_sessions_root, + known_paths: Vec::new(), + }, + ], + SourcesConfig::default(), + ); + fs::write( + root.join(CONFIG_FILE_NAME), + toml::to_string_pretty(&config)?, + )?; + let index_db_path = root.join(darc_store::INDEX_DB_FILE_NAME); + write_file(&index_db_path, "not a sqlite database")?; + + let report = rebuild_workspace_index(Some(root.clone()))?; + + assert_eq!(report.projects.len(), 2); + assert_eq!(report.sessions_currently_indexed(), 2); + assert_eq!(report.turns_currently_indexed(), 2); + + let connection = Connection::open(index_db_path)?; + let rows = connection + .prepare( + " + SELECT project_id, user_message, final_answer_text + FROM turns + ORDER BY project_id ASC + ", + )? + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + + assert_eq!( + rows, + vec![ + ( + "repo-one-123".to_owned(), + "Index first project".to_owned(), + "First indexed".to_owned() + ), + ( + "repo-two-456".to_owned(), + "Index second project".to_owned(), + "Second indexed".to_owned() + ), + ] + ); + + Ok(()) + } + + #[cfg(unix)] + #[test] + fn rebuild_workspace_index_keeps_existing_index_when_project_fails() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let root = unique_test_dir("workspace-rebuild-failure"); + let first_project_root = root.join("repo-one"); + let second_project_root = root.join("repo-two"); + let first_sessions_root = root.join("projects/repo-one-123/sessions"); + let second_sessions_root = root.join("projects/repo-two-456/sessions"); + fs::create_dir_all(&first_project_root)?; + fs::create_dir_all(&second_project_root)?; + write_archived_codex_rollout( + &first_sessions_root, + &first_project_root, + "44444444-4444-4444-8444-44444444444f", + "Index first project", + "First indexed", + )?; + let unreadable_rollout = second_sessions_root + .join("codex") + .join("rollout-2026-04-01T10-00-00-55555555-5555-4555-8555-55555555555f.jsonl"); + write_file(&unreadable_rollout, "unreadable")?; + fs::set_permissions(&unreadable_rollout, fs::Permissions::from_mode(0o000))?; + + let config = SharedConfig::new( + root.clone(), + vec![ + ProjectConfig { + id: "repo-one-123".into(), + name: "repo-one".into(), + local_path: first_project_root, + git_upstream: None, + sessions_root: first_sessions_root, + known_paths: Vec::new(), + }, + ProjectConfig { + id: "repo-two-456".into(), + name: "repo-two".into(), + local_path: second_project_root, + git_upstream: None, + sessions_root: second_sessions_root, + known_paths: Vec::new(), + }, + ], + SourcesConfig::default(), + ); + fs::write( + root.join(CONFIG_FILE_NAME), + toml::to_string_pretty(&config)?, + )?; + let index_db_path = root.join(darc_store::INDEX_DB_FILE_NAME); + let connection = Connection::open(&index_db_path)?; + connection.execute_batch( + " + CREATE TABLE preserved(value TEXT NOT NULL); + INSERT INTO preserved(value) VALUES ('old index'); + ", + )?; + drop(connection); + + let error = rebuild_workspace_index(Some(root.clone())).expect_err("rebuild should fail"); + fs::set_permissions(&unreadable_rollout, fs::Permissions::from_mode(0o600))?; + + assert!(format!("{error:#}").contains("failed to index project `repo-two`")); + let connection = Connection::open(&index_db_path)?; + let value: String = + connection.query_row("SELECT value FROM preserved", [], |row| row.get(0))?; + assert_eq!(value, "old index"); + + Ok(()) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b1445d5..7dbbe7d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,9 +9,11 @@ mod status; mod sync; pub use config::SourceKind; +pub use darc_store::IndexDatabaseRebuildRecommendation; pub use index::{ - IndexOptions, IndexReport, SkippedCodexRollout, SkippedRollout, index_project_codex_turns, - index_project_sessions, + IndexOptions, IndexReport, SkippedCodexRollout, SkippedRollout, WorkspaceIndexReport, + index_project_codex_turns, index_project_sessions, index_rebuild_command, + rebuild_workspace_index, }; pub use init::{DetectedRolloutSource, InitDraft, default_root_path, prepare_init, write_init}; pub use project::{ diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 8b5c5ce..9f3fd2c 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -6,7 +6,7 @@ mod tests; mod types; mod workflow; -pub(crate) use registry::write_shared_config; +pub(crate) use registry::{registered_projects, write_shared_config}; pub use types::{ LinkReport, RefreshAllBestEffortReport, RefreshAllReport, RefreshOptions, RefreshProgress, RefreshProjectAttempt, RefreshProjectFailure, RefreshReport, RemovePreviewReport, RemoveReport, diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index be5d21d..a104b9c 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -32,7 +32,7 @@ use darc_query::{ query_session_turn_details as query_project_session_turn_details, query_turn_detail, query_turn_insights, query_workspace_insights, }; -use darc_store::INDEX_DB_FILE_NAME; +use darc_store::{INDEX_DB_FILE_NAME, IndexDatabaseRebuildRecommendation}; use serde_json::{Value as JsonValue, json}; use thiserror::Error; @@ -40,7 +40,7 @@ use crate::{ active_project::{is_no_active_project_error, load_active_project}, config::{ProjectConfig, SharedConfig, load_config}, constants::CONFIG_FILE_NAME, - default_root_path, + default_root_path, index_rebuild_command, init::normalize_project_config, }; @@ -97,10 +97,9 @@ pub fn query_workspace(root: Option) -> WorkspaceQueryData { match list_project_index_aggregates(&root_info.database_path) { Ok(aggregates) => aggregate_map(aggregates), Err(error) => { - root_info.issues.push(format!( - "Darc database could not be queried: {}", - error.root_cause() - )); + root_info + .issues + .push(format_workspace_database_issue(&error)); Default::default() } } @@ -143,6 +142,22 @@ pub fn query_workspace(root: Option) -> WorkspaceQueryData { } } +/// Formats one workspace database issue with index rebuild guidance when possible. +fn format_workspace_database_issue(error: &anyhow::Error) -> String { + if let Some(rebuild) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return format!( + "Darc database could not be queried: local SQLite index at {} needs to be rebuilt; run `{}` to rebuild the shared index cache from archived sessions", + rebuild.path().display(), + index_rebuild_command(rebuild.path()) + ); + } + + format!("Darc database could not be queried: {}", error.root_cause()) +} + /// Stores one resolved project-scoped query target plus its root metadata. #[derive(Debug, Clone)] pub struct ResolvedQueryProject { @@ -1197,3 +1212,61 @@ struct ProjectQueryContext { root: RootInfo, project: ProjectConfig, } + +#[cfg(test)] +mod tests { + use std::fs; + + use anyhow::Result; + use darc_test_utils::{unique_test_dir, write_file}; + + use super::query_workspace; + use crate::{ + config::{ProjectConfig, SharedConfig, SourcesConfig}, + constants::CONFIG_FILE_NAME, + index_rebuild_command, + }; + + #[test] + fn query_workspace_issue_recommends_rebuild_for_unusable_index() -> Result<()> { + let root = unique_test_dir("workspace-query-rebuild"); + let project_root = root.join("repo"); + let sessions_root = root.join("projects/repo-123/sessions"); + fs::create_dir_all(&project_root)?; + let config = SharedConfig::new( + root.clone(), + vec![ProjectConfig { + id: "repo-123".to_owned(), + name: "repo".to_owned(), + local_path: project_root, + git_upstream: None, + sessions_root, + known_paths: Vec::new(), + }], + SourcesConfig::default(), + ); + write_file( + &root.join(CONFIG_FILE_NAME), + &toml::to_string_pretty(&config)?, + )?; + let index_db_path = root.join(darc_store::INDEX_DB_FILE_NAME); + write_file(&index_db_path, "not a sqlite database")?; + + let workspace = query_workspace(Some(root)); + let issue = workspace + .root + .issues + .first() + .expect("workspace should report database issue"); + + assert!(issue.contains("Darc database could not be queried")); + assert!(issue.contains("needs to be rebuilt")); + assert!(issue.contains(&format!( + "run `{}`", + index_rebuild_command(&workspace.root.database_path) + ))); + assert!(issue.contains(&workspace.root.database_path.display().to_string())); + assert_eq!(workspace.projects.len(), 1); + Ok(()) + } +} diff --git a/crates/query/src/query/files.rs b/crates/query/src/query/files.rs index 003665a..bfc111e 100644 --- a/crates/query/src/query/files.rs +++ b/crates/query/src/query/files.rs @@ -1533,8 +1533,6 @@ fn normalize_path_literal(path: &str) -> String { let (prefix, remainder, absolute) = if let Some(remainder) = path.strip_prefix('/') { (Some("/".to_owned()), remainder, true) - } else if let Some(remainder) = strip_windows_drive_root(&path) { - (Some(path[..2].to_owned()), remainder, true) } else { (None, path.as_str(), false) }; @@ -1581,19 +1579,9 @@ fn strip_root_prefix_from_path(path: &str, root: &str) -> Option { .map(str::to_owned) } -/// Returns whether one normalized path literal is absolute on common host platforms. +/// Returns whether one normalized path literal is an absolute POSIX path. fn is_absolute_path_literal(path: &str) -> bool { - path.starts_with('/') || strip_windows_drive_root(path).is_some() -} - -/// Removes one `C:/`-style drive prefix when present. -fn strip_windows_drive_root(path: &str) -> Option<&str> { - let bytes = path.as_bytes(); - if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' { - Some(&path[3..]) - } else { - None - } + path.starts_with('/') } /// Yields the macOS `/private` toggled variants for one normalized absolute path. diff --git a/crates/rollout-audit/src/claude.rs b/crates/rollout-audit/src/claude.rs index 613a98b..70edf2f 100644 --- a/crates/rollout-audit/src/claude.rs +++ b/crates/rollout-audit/src/claude.rs @@ -396,11 +396,7 @@ impl AuditRuntime { Ok(Self { node_binary, node_platform_suffix, - hook_python: resolve_runtime_binary(if cfg!(windows) { - &["python", "python3"] - } else { - &["python3", "python"] - })?, + hook_python: resolve_runtime_binary(&["python3", "python"])?, }) } } @@ -2313,8 +2309,6 @@ fn detect_node_native_cli_platform_suffix(node_binary: &Path) -> Result | "linux-arm64-musl" | "linux-x64" | "linux-x64-musl" - | "win32-arm64" - | "win32-x64" ), "unsupported Node platform suffix `{suffix}` for Claude native packages" ); @@ -2371,12 +2365,8 @@ fn node_modules_package_path(cli_root: &Path, package_name: &str) -> Result &'static str { - if platform_suffix.starts_with("win32-") { - "claude.exe" - } else { - "claude" - } +fn native_cli_binary_name(_platform_suffix: &str) -> &'static str { + "claude" } /// Marks one extracted package file executable on Unix hosts. @@ -3124,11 +3114,7 @@ fn sanitize_for_path(text: &str) -> String { /// Quotes one string for a shell command embedded in Claude hook settings. fn shell_quote(text: &str) -> String { - if cfg!(windows) { - format!("\"{}\"", text.replace('"', "\\\"")) - } else { - format!("'{}'", text.replace('\'', "'\"'\"'")) - } + format!("'{}'", text.replace('\'', "'\"'\"'")) } /// Encodes one raw byte slice as unwrapped standard base64 text. @@ -3228,7 +3214,7 @@ mod tests { #[test] fn resolves_native_binary_name_from_node_platform() { assert_eq!(super::native_cli_binary_name("darwin-arm64"), "claude"); - assert_eq!(super::native_cli_binary_name("win32-x64"), "claude.exe"); + assert_eq!(super::native_cli_binary_name("linux-x64"), "claude"); } #[test] diff --git a/crates/rollout-audit/src/codex.rs b/crates/rollout-audit/src/codex.rs index 3ffbfb7..0f86d73 100644 --- a/crates/rollout-audit/src/codex.rs +++ b/crates/rollout-audit/src/codex.rs @@ -490,32 +490,9 @@ fn build_released_binary_command( command.env("XDG_STATE_HOME", &xdg_state); command.env("XDG_RUNTIME_DIR", &xdg_runtime); - #[cfg(windows)] - { - let appdata = runtime_home.join("AppData").join("Roaming"); - let local_appdata = runtime_home.join("AppData").join("Local"); - for path in [&appdata, &local_appdata] { - fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - } - command.env("USERPROFILE", &runtime_home); - command.env("APPDATA", &appdata); - command.env("LOCALAPPDATA", &local_appdata); - copy_env_if_present(&mut command, "SystemRoot"); - copy_env_if_present(&mut command, "WINDIR"); - } - Ok(command) } -/// Copies one inherited environment variable into a child process when it exists. -#[cfg(windows)] -fn copy_env_if_present(command: &mut Command, name: &str) { - if let Some(value) = env::var_os(name) { - command.env(name, value); - } -} - /// Stores one supported host platform for released Codex binaries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct HostPlatform { @@ -583,18 +560,6 @@ fn host_platform_from_parts(os: &str, arch: &str) -> Result { binary_file_name: "codex", display_name: "Linux x86_64", }), - ("windows", "aarch64") => Ok(HostPlatform { - release_asset_suffix: "win32-arm64", - vendor_target: "aarch64-pc-windows-msvc", - binary_file_name: "codex.exe", - display_name: "Windows arm64", - }), - ("windows", "x86_64") => Ok(HostPlatform { - release_asset_suffix: "win32-x64", - vendor_target: "x86_64-pc-windows-msvc", - binary_file_name: "codex.exe", - display_name: "Windows x86_64", - }), _ => bail!("unsupported host platform `{os}` / `{arch}` for released Codex binaries"), } } diff --git a/crates/store/src/index_db.rs b/crates/store/src/index_db.rs index 2bcbe28..591e235 100644 --- a/crates/store/src/index_db.rs +++ b/crates/store/src/index_db.rs @@ -1,10 +1,17 @@ mod migrations; pub(crate) mod schema; -use std::{fs, path::Path, time::Duration}; +use std::{ + error::Error as StdError, + ffi::OsString, + fmt, fs, + io::ErrorKind, + path::{Path, PathBuf}, + time::Duration, +}; use anyhow::{Context, Result}; -use rusqlite::{Connection, OpenFlags, params}; +use rusqlite::{Connection, ErrorCode, OpenFlags, params}; use self::{ migrations::{ @@ -23,6 +30,44 @@ pub const INDEX_DB_FILE_NAME: &str = "index.sqlite"; /// Tracks one-shot SQLite migrations for normalized index tables. const INDEX_DB_SCHEMA_VERSION: i32 = 13; +/// Describes an existing index database that should be rebuilt from archived sessions. +#[derive(Debug)] +pub struct IndexDatabaseRebuildRecommendation { + path: PathBuf, + source: anyhow::Error, +} + +impl IndexDatabaseRebuildRecommendation { + /// Wraps one failed SQLite open or migration with user-facing rebuild guidance. + fn new(path: &Path, source: anyhow::Error) -> Self { + Self { + path: path.to_path_buf(), + source, + } + } + + /// Returns the SQLite index path that should be rebuilt. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl fmt::Display for IndexDatabaseRebuildRecommendation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "local SQLite index at {} could not be opened or migrated", + self.path.display() + ) + } +} + +impl StdError for IndexDatabaseRebuildRecommendation { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.source.as_ref()) + } +} + /// Opens or creates the index database for write-side commands that may initialize storage. pub fn open_index_database_writer(path: &Path) -> Result { create_parent_dir(path)?; @@ -38,6 +83,12 @@ pub fn open_existing_index_database(path: &Path) -> Result { /// Opens an existing index database read-only without initializing or migrating storage. pub fn open_index_database_read_only(path: &Path) -> Result { ensure_existing_database_path(path)?; + open_index_database_read_only_inner(path) + .or_else(|error| recommend_rebuild_for_index_error(path, true, error)) +} + +/// Opens an existing index database read-only without wrapping rebuild guidance. +fn open_index_database_read_only_inner(path: &Path) -> Result { let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .with_context(|| format!("failed to open index database {} read-only", path.display()))?; connection @@ -53,6 +104,13 @@ pub fn open_index_database(path: &Path) -> Result { /// Opens one SQLite connection and applies all supported index migrations. fn open_migrating_connection(path: &Path) -> Result { + let existing_database = path.exists(); + open_migrating_connection_inner(path) + .or_else(|error| recommend_rebuild_for_index_error(path, existing_database, error)) +} + +/// Opens one SQLite connection and runs migration without wrapping rebuild guidance. +fn open_migrating_connection_inner(path: &Path) -> Result { let mut connection = Connection::open(path) .with_context(|| format!("failed to open index database {}", path.display()))?; connection @@ -68,6 +126,15 @@ pub fn count_project_index_rows_read_only(path: &Path, project_id: &str) -> Resu return Ok((0, 0)); } + count_project_index_rows_read_only_inner(path, project_id) + .or_else(|error| recommend_rebuild_for_index_error(path, true, error)) +} + +/// Counts one project's indexed rows from an existing read-only database. +fn count_project_index_rows_read_only_inner( + path: &Path, + project_id: &str, +) -> Result<(usize, usize)> { let connection = open_index_database_read_only(path)?; let mut session_count = @@ -94,6 +161,73 @@ pub fn ensure_index_database(path: &Path) -> Result<()> { Ok(()) } +/// Removes one SQLite index database and its common sidecar files. +pub fn remove_index_database(path: &Path) -> Result { + let mut removed = 0; + for candidate in index_database_file_set(path)? { + match fs::remove_file(&candidate) { + Ok(()) => removed += 1, + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to remove index database file {}", + candidate.display() + ) + }); + } + } + } + Ok(removed) +} + +/// Replaces one SQLite index database with a fully built sibling database. +pub fn replace_index_database(replacement: &Path, destination: &Path) -> Result<()> { + ensure_existing_database_path(replacement)?; + for sidecar in index_database_file_set(destination)?.into_iter().skip(1) { + match fs::remove_file(&sidecar) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to remove stale index database sidecar {}", + sidecar.display() + ) + }); + } + } + } + + replace_index_database_file(replacement, destination)?; + for sidecar in index_database_file_set(replacement)?.into_iter().skip(1) { + match fs::remove_file(&sidecar) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to remove temporary index database sidecar {}", + sidecar.display() + ) + }); + } + } + } + Ok(()) +} + +/// Replaces one SQLite database file after the replacement has been fully built. +fn replace_index_database_file(replacement: &Path, destination: &Path) -> Result<()> { + fs::rename(replacement, destination).with_context(|| { + format!( + "failed to replace index database {} with {}", + destination.display(), + replacement.display() + ) + }) +} + /// Counts rows for one project in a table when that table already exists. fn count_project_rows_if_table_exists( connection: &Connection, @@ -177,6 +311,100 @@ fn managed_tables_are_missing(connection: &Connection, tables: &[SchemaTable]) - Ok(false) } +/// Wraps one existing-index failure with rebuild guidance when appropriate. +fn recommend_rebuild_for_index_error( + path: &Path, + existing_database: bool, + error: anyhow::Error, +) -> Result { + if !existing_database + || error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + }) + || !index_database_error_recommends_rebuild(&error) + { + return Err(error); + } + + Err(IndexDatabaseRebuildRecommendation::new(path, error).into()) +} + +/// Returns whether one failed existing index open should recommend a rebuild. +fn index_database_error_recommends_rebuild(error: &anyhow::Error) -> bool { + let mut saw_sqlite_error = false; + for cause in error.chain() { + if let Some(sqlite_error) = cause.downcast_ref::() { + saw_sqlite_error = true; + if sqlite_error_blocks_rebuild_recommendation(sqlite_error) { + return false; + } + } + if let Some(io_error) = cause.downcast_ref::() + && matches!( + io_error.kind(), + ErrorKind::PermissionDenied | ErrorKind::Interrupted + ) + { + return false; + } + } + + saw_sqlite_error || error.chain().any(error_is_index_migration_context) +} + +/// Returns whether one SQLite error points to an access/runtime problem instead. +fn sqlite_error_blocks_rebuild_recommendation(error: &rusqlite::Error) -> bool { + matches!( + error.sqlite_error_code(), + Some( + ErrorCode::PermissionDenied + | ErrorCode::DatabaseBusy + | ErrorCode::DatabaseLocked + | ErrorCode::OutOfMemory + | ErrorCode::ReadOnly + | ErrorCode::OperationInterrupted + | ErrorCode::SystemIoFailure + | ErrorCode::DiskFull + | ErrorCode::CannotOpen + ) + ) +} + +/// Returns whether one error message is from the index migration/rebuild phase. +fn error_is_index_migration_context(error: &(dyn StdError + 'static)) -> bool { + let message = error.to_string(); + message.contains("index database") + || message.contains("schema-version migration") + || message.contains("legacy Codex migration") + || message.contains("derived analytics") + || message.contains("stored steps_json") +} + +/// Returns the main SQLite database path and its common sidecar paths. +fn index_database_file_set(path: &Path) -> Result<[PathBuf; 4]> { + Ok([ + path.to_path_buf(), + index_database_sidecar_path(path, "-wal")?, + index_database_sidecar_path(path, "-shm")?, + index_database_sidecar_path(path, "-journal")?, + ]) +} + +/// Returns one SQLite sidecar path by appending a suffix to the database filename. +fn index_database_sidecar_path(path: &Path, suffix: &str) -> Result { + let file_name = path.file_name().with_context(|| { + format!( + "index database path {} is missing a filename", + path.display() + ) + })?; + let mut sidecar_name = OsString::from(file_name); + sidecar_name.push(suffix); + Ok(path.with_file_name(sidecar_name)) +} + /// Ensures one existing SQLite database path is available before opening it. fn ensure_existing_database_path(path: &Path) -> Result<()> { if !path.exists() { @@ -197,7 +425,7 @@ fn create_parent_dir(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use std::{ - env, + env, fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; @@ -207,8 +435,9 @@ mod tests { use rusqlite::Connection; use super::{ - INDEX_DB_SCHEMA_VERSION, count_project_index_rows_read_only, migrations, - open_index_database, schema, + INDEX_DB_SCHEMA_VERSION, IndexDatabaseRebuildRecommendation, + count_project_index_rows_read_only, migrations, open_existing_index_database, + open_index_database, remove_index_database, schema, }; use crate::test_support::{ IndexedSessionFixture, IndexedTurnFixture, create_pre_analytics_index_schema, @@ -333,6 +562,85 @@ mod tests { Ok(()) } + #[test] + fn open_index_database_recommends_rebuild_for_unreadable_sqlite_cache() -> Result<()> { + let path = unique_db_path("index-db-corrupt-cache"); + fs::write(&path, "not a sqlite database")?; + + let error = open_index_database(&path).expect_err("corrupt index should fail"); + let rebuild = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .expect("existing corrupt index should carry rebuild guidance"); + + assert_eq!(rebuild.path(), path.as_path()); + assert!(error.to_string().contains("local SQLite index at")); + assert!(format!("{error:#}").contains("file is not a database")); + + Ok(()) + } + + #[test] + fn open_existing_index_database_does_not_recommend_rebuild_when_index_is_missing() { + let path = unique_db_path("index-db-missing-cache"); + + let error = open_existing_index_database(&path).expect_err("missing index should fail"); + + assert!(!error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + })); + assert!(!error.to_string().contains("darc index --rebuild")); + } + + #[test] + fn count_project_index_rows_read_only_recommends_rebuild_for_corrupt_index() -> Result<()> { + let path = unique_db_path("index-db-corrupt-read-only-cache"); + fs::write(&path, "not a sqlite database")?; + + let error = count_project_index_rows_read_only(&path, "repo-abc123") + .expect_err("corrupt read-only index should fail"); + + assert!(error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + })); + assert!(error.to_string().contains("local SQLite index at")); + + Ok(()) + } + + #[test] + fn remove_index_database_removes_sqlite_sidecars() -> Result<()> { + let path = unique_db_path("index-db-remove-sidecars"); + let wal_path = path.with_file_name(format!( + "{}-wal", + path.file_name().expect("filename").to_string_lossy() + )); + let shm_path = path.with_file_name(format!( + "{}-shm", + path.file_name().expect("filename").to_string_lossy() + )); + let journal_path = path.with_file_name(format!( + "{}-journal", + path.file_name().expect("filename").to_string_lossy() + )); + for candidate in [&path, &wal_path, &shm_path, &journal_path] { + fs::write(candidate, "cache")?; + } + + let removed = remove_index_database(&path)?; + + assert_eq!(removed, 4); + for candidate in [&path, &wal_path, &shm_path, &journal_path] { + assert!(!candidate.exists()); + } + + Ok(()) + } + #[test] fn open_index_database_migrates_legacy_codex_rows_into_normalized_tables() -> Result<()> { let path = unique_db_path("index-db-migrate"); @@ -989,11 +1297,8 @@ mod tests { drop(connection); let error = open_index_database(&path).expect_err("rebuild should fail"); - assert!( - error - .to_string() - .contains("failed to parse stored steps_json") - ); + assert!(error.to_string().contains("local SQLite index at")); + assert!(format!("{error:#}").contains("failed to parse stored steps_json")); let reopened = Connection::open(&path)?; let state: (i64, i64, i32) = reopened.query_row( diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index fa1fce9..f1d6695 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -10,9 +10,10 @@ mod turn_metrics; mod write; pub use index_db::{ - INDEX_DB_FILE_NAME, count_project_index_rows_read_only, ensure_index_database, - open_existing_index_database, open_index_database, open_index_database_read_only, - open_index_database_writer, + INDEX_DB_FILE_NAME, IndexDatabaseRebuildRecommendation, count_project_index_rows_read_only, + ensure_index_database, open_existing_index_database, open_index_database, + open_index_database_read_only, open_index_database_writer, remove_index_database, + replace_index_database, }; pub use write::{ StoredSessionKind, StoredSessionRecord, StoredTurnRecord, insert_session_record,