mod cli;
mod config;
mod display;
mod error;
mod models;
mod query;
mod storage;
mod utils;
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use cli::Commands;
use config::{load_config, Config};
use display::{print_entry, DisplayOptions};
use error::DevbrainError;
use models::{Entry, EntryType};
use query::{find_last_entry, parse_terms, process_entries, EntryFilters};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::fs::OpenOptions;
use std::hash::{Hash, Hasher};
use std::io::{self, IsTerminal, Write};
use std::panic;
use std::path::Path;
use std::process;
use std::str::FromStr;
use storage::Database;
struct SearchOptions {
query: String,
project: Option<String>,
entry_type: Option<EntryType>,
tags: Option<Vec<String>>,
since: Option<Duration>,
limit: Option<usize>,
offset: Option<usize>,
session: bool,
all: bool,
json: bool,
success: Option<bool>,
}
struct TimelineOptions {
project: Option<String>,
entry_type: Option<EntryType>,
since: Option<Duration>,
limit: Option<usize>,
offset: Option<usize>,
session: bool,
all: bool,
json: bool,
}
struct RecentEntriesOptions {
project: Option<String>,
entry_type: EntryType,
since: Option<Duration>,
limit: Option<usize>,
session: bool,
all: bool,
json: bool,
}
struct DatabaseSearchOptions {
query: String,
project: Option<String>,
entry_type: Option<EntryType>,
tags: Option<Vec<String>>,
session_id: Option<String>,
success: Option<bool>,
since: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
}
fn main() {
let exit_code = match panic::catch_unwind(run) {
Ok(Ok(())) => 0,
Ok(Err(error)) => {
eprintln!("{error}");
1
}
Err(_) => {
eprintln!("Internal error: unexpected failure");
1
}
};
if exit_code != 0 {
process::exit(exit_code);
}
}
fn run() -> Result<(), DevbrainError> {
let cli = cli::parse()?;
validate_cli_args(&cli.command)?;
let config = load_config()?;
let display_options = DisplayOptions {
color: !cli.no_color && io::stdout().is_terminal(),
};
match cli.command {
Commands::Log { message, tag } => log_message(&config, message, tag)?,
Commands::LogCmd { command, tag } => log_command(&config, command, tag)?,
Commands::ShellLog {
command,
status,
session,
} => shell_log_command(&config, command, status, session)?,
Commands::LogError { error, tag } => log_error(&config, error, tag)?,
Commands::Search {
query,
all,
json,
limit,
recent,
offset,
entry_type,
since,
success,
failed,
session,
} => {
let success = validate_success_filter(success, failed)?;
let entry_type = validate_entry_type(entry_type)?;
let limit = resolve_recent_limit(limit, recent);
let since = match since {
Some(since) => Some(parse_duration(&since)?),
None => None,
};
let project = project_filter(all);
search_entries(
&config,
display_options,
SearchOptions {
query,
project,
entry_type,
tags: None,
since,
limit,
offset,
session,
all,
json,
success,
},
)?
}
Commands::Timeline {
all,
json,
limit,
offset,
entry_type,
since,
session,
} => {
let entry_type = validate_entry_type(entry_type)?;
let since = match since {
Some(since) => Some(parse_duration(&since)?),
None => None,
};
let project = project_filter(all);
show_timeline(
&config,
display_options,
TimelineOptions {
project,
entry_type,
since,
limit,
offset,
session,
all,
json,
},
)?
}
Commands::Explore => explore_entries(&config, display_options)?,
Commands::Errors {
all,
json,
limit,
recent,
since,
} => {
let limit = resolve_recent_limit(limit, recent);
let since = match since {
Some(since) => Some(parse_duration(&since)?),
None => None,
};
let project = project_filter(all);
show_recent_entries_by_type(
&config,
display_options,
RecentEntriesOptions {
project,
entry_type: EntryType::Error,
since,
limit,
session: false,
all,
json,
},
)?
}
Commands::CmdHistory {
all,
json,
limit,
recent,
since,
session,
} => {
let limit = resolve_recent_limit(limit, recent);
let since = match since {
Some(since) => Some(parse_duration(&since)?),
None => None,
};
let project = project_filter(all);
show_recent_entries_by_type(
&config,
display_options,
RecentEntriesOptions {
project,
entry_type: EntryType::Command,
since,
limit,
session,
all,
json,
},
)?
}
Commands::Session { json, limit } => show_recent_entries_by_type(
&config,
display_options,
RecentEntriesOptions {
project: None,
entry_type: EntryType::Command,
since: None,
limit,
session: true,
all: true,
json,
},
)?,
Commands::Last { all, json } => show_last_entry(&config, display_options, all, json)?,
Commands::Clear { force } => clear_entries(&config, force)?,
Commands::Cleanup { older_than } => cleanup_entries(&config, older_than)?,
Commands::Export { output } => export_entries(&config, output)?,
Commands::Import { input, merge } => import_entries(&config, input, merge)?,
Commands::Doctor => doctor(&config)?,
Commands::Stats => show_stats(&config)?,
Commands::Project => show_project_summary(&config)?,
Commands::Debug => show_debug_view(&config, display_options)?,
Commands::Preset { args } => handle_preset_command(&config, display_options, &args)?,
Commands::Suggest { query } => show_command_suggestions(&config, &query)?,
}
Ok(())
}
fn doctor(config: &Config) -> Result<(), DevbrainError> {
let db_path = &config.db_path;
let exists = db_path.exists();
let readable = if exists {
fs::File::open(db_path).is_ok()
} else {
false
};
let writable = if exists {
OpenOptions::new().write(true).open(db_path).is_ok()
} else {
false
};
print_check("DB exists", exists);
print_check("DB readable", readable);
print_check("DB writable", writable);
let (valid_json, has_version) = if readable {
match fs::read_to_string(db_path) {
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Ok(json) => (true, json.get("version").is_some()),
Err(_) => (false, false),
},
Err(_) => (false, false),
}
} else {
(false, false)
};
let corrupted = readable && (!valid_json || !has_version);
print_check("DB valid JSON", valid_json);
print_check("DB version field exists", has_version);
if corrupted {
print_check("DB corrupted", false);
println!("try export/import or reset");
}
Ok(())
}
fn print_check(label: &str, ok: bool) {
let symbol = if ok { "✔" } else { "✖" };
println!("{symbol} {label}");
}
fn log_message(config: &Config, message: String, tags: Vec<String>) -> Result<(), DevbrainError> {
create_and_store_entry(config, EntryType::Log, message, tags, false, None)?;
println!("Logged successfully");
Ok(())
}
fn log_command(config: &Config, command: String, tags: Vec<String>) -> Result<(), DevbrainError> {
create_and_store_entry(config, EntryType::Command, command, tags, true, None)?;
println!("Command logged");
Ok(())
}
fn log_error(config: &Config, error: String, tags: Vec<String>) -> Result<(), DevbrainError> {
create_and_store_entry(config, EntryType::Error, error, tags, false, None)?;
println!("Error logged");
Ok(())
}
fn shell_log_command(
config: &Config,
command: String,
status: Option<i32>,
session: Option<String>,
) -> Result<(), DevbrainError> {
if std::env::var_os("DEVBRAIN_DISABLE_SHELL_CAPTURE").is_some() {
return Ok(());
}
let command = match utils::normalize_command_input(&command) {
Ok(command) => command,
Err(_) => return Ok(()),
};
if should_ignore_command(&command) {
return Ok(());
}
let mut db = storage::load_db(config)?;
if db
.entries
.last()
.is_some_and(|entry| entry.entry_type == EntryType::Command && entry.content == command)
{
return Ok(());
}
let tags = utils::infer_tags(&command);
let entry = Entry {
entry_type: EntryType::Command,
content: command,
project: utils::get_project_name(),
timestamp: utils::get_timestamp(),
session_id: session,
success: status.map(|status| status == 0),
tags,
};
db.entries.push(entry);
storage::save_db(config, &db)?;
Ok(())
}
fn should_ignore_command(command: &str) -> bool {
if command.is_empty() || command.len() < 2 {
return true;
}
if command.starts_with("devbrain") {
return true;
}
let trivial_commands = ["ls", "cd", "pwd", "clear", "history", "exit"];
if trivial_commands
.iter()
.any(|trivial| command == *trivial || command.starts_with(&format!("{trivial} ")))
{
return true;
}
command.starts_with("cargo install") || command.starts_with("cargo build")
}
fn create_and_store_entry(
config: &Config,
entry_type: EntryType,
content: String,
tags: Vec<String>,
normalize_as_command: bool,
success: Option<bool>,
) -> Result<(), DevbrainError> {
let content = if normalize_as_command {
utils::normalize_command_input(&content)?
} else {
utils::normalize_input(&content)?
};
let mut tags = utils::normalize_tags(&tags);
for inferred_tag in utils::infer_tags(&content) {
if !tags.contains(&inferred_tag) {
tags.push(inferred_tag);
}
}
let entry = Entry {
entry_type,
content,
project: utils::get_project_name(),
timestamp: utils::get_timestamp(),
session_id: None,
success,
tags,
};
storage::add_entry(config, entry)?;
Ok(())
}
fn search_entries(
config: &Config,
display_options: DisplayOptions,
opts: SearchOptions,
) -> Result<(), DevbrainError> {
let SearchOptions {
query,
project,
entry_type,
tags,
since,
limit,
offset,
session,
all: _all,
json,
success,
} = opts;
let session_id = current_session_filter(session)?;
let since_cutoff =
since.map(|duration| (Utc::now() - duration).to_rfc3339_opts(SecondsFormat::Secs, true));
let db = storage::load_db(config)?;
let entries = search_entries_in_db(&db, DatabaseSearchOptions {
query: query.clone(),
project,
entry_type,
tags,
session_id,
success,
since: since_cutoff,
offset,
limit,
});
if entries.is_empty() && !json {
println!("No matching entries found");
return Ok(());
}
let terms = parse_terms(&query);
print_entries(
&entries,
&db.entries,
json,
terms.as_deref(),
display_options,
)?;
Ok(())
}
fn explore_entries(config: &Config, display_options: DisplayOptions) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
let project = project_filter(false);
loop {
print!("> ");
io::stdout()
.flush()
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let query = input.trim();
if query.is_empty() || query.eq_ignore_ascii_case("exit") {
break;
}
let entries = search_entries_in_db(
&db,
DatabaseSearchOptions {
query: query.to_string(),
project: project.clone(),
entry_type: None,
tags: None,
session_id: None,
success: None,
since: None,
offset: None,
limit: Some(5),
},
);
if entries.is_empty() {
println!("No matching entries found");
} else {
let terms = parse_terms(query);
print_entries(
&entries,
&db.entries,
false,
terms.as_deref(),
display_options,
)?;
}
}
Ok(())
}
fn show_timeline(
config: &Config,
display_options: DisplayOptions,
opts: TimelineOptions,
) -> Result<(), DevbrainError> {
let TimelineOptions {
project,
entry_type,
since,
limit,
offset,
session,
all: _all,
json,
} = opts;
let session_id = current_session_filter(session)?;
let since_cutoff =
since.map(|duration| (Utc::now() - duration).to_rfc3339_opts(SecondsFormat::Secs, true));
let db = storage::load_db(config)?;
let filters = EntryFilters {
query: None,
project: project.as_deref(),
entry_type: entry_type.as_ref(),
session_id: session_id.as_deref(),
success: None,
since: since_cutoff.as_deref(),
offset,
limit,
};
let entries = process_entries(&db.entries, &filters);
if entries.is_empty() && !json {
println!("No entries found");
return Ok(());
}
print_entries(&entries, &db.entries, json, None, display_options)?;
Ok(())
}
fn show_last_entry(
config: &Config,
display_options: DisplayOptions,
all: bool,
json: bool,
) -> Result<(), DevbrainError> {
let project = project_filter(all);
let db = storage::load_db(config)?;
let filters = EntryFilters {
query: None,
project: project.as_deref(),
entry_type: None,
session_id: None,
success: None,
since: None,
offset: None,
limit: Some(1),
};
if let Some(entry) = find_last_entry(&db.entries, &filters) {
print_entry_output(Some(entry), &db.entries, json, None, display_options)?;
} else if json {
print_entry_output(None, &db.entries, true, None, display_options)?;
} else {
println!("No entries found");
}
Ok(())
}
fn show_recent_entries_by_type(
config: &Config,
display_options: DisplayOptions,
opts: RecentEntriesOptions,
) -> Result<(), DevbrainError> {
let RecentEntriesOptions {
project,
entry_type,
since,
limit,
session,
all: _all,
json,
} = opts;
let session_id = current_session_filter(session)?;
let since_cutoff =
since.map(|duration| (Utc::now() - duration).to_rfc3339_opts(SecondsFormat::Secs, true));
let db = storage::load_db(config)?;
let filters = EntryFilters {
query: None,
project: project.as_deref(),
entry_type: Some(&entry_type),
session_id: session_id.as_deref(),
success: None,
since: since_cutoff.as_deref(),
offset: None,
limit,
};
let entries = process_entries(&db.entries, &filters);
if entries.is_empty() && !json {
println!("No entries found");
return Ok(());
}
print_entries(&entries, &db.entries, json, None, display_options)?;
Ok(())
}
fn clear_entries(config: &Config, force: bool) -> Result<(), DevbrainError> {
if !force && !confirm_clear()? {
println!("Operation cancelled");
return Ok(());
}
let db = Database {
version: storage::DB_VERSION.to_string(),
entries: Vec::new(),
};
storage::save_db(config, &db)?;
println!("All entries cleared");
Ok(())
}
fn cleanup_entries(config: &Config, older_than: Option<i64>) -> Result<(), DevbrainError> {
if older_than.is_some_and(|days| days < 0) {
return Err(DevbrainError::Other(
"--older-than must be 0 or greater".to_string(),
));
}
let mut db = storage::load_db(config)?;
let original_len = db.entries.len();
let cutoff = older_than.map(|days| Utc::now() - Duration::days(days));
let mut seen = HashSet::new();
let mut removed_empty = 0;
let mut removed_duplicates = 0;
let mut removed_old = 0;
let mut cleaned = Vec::with_capacity(db.entries.len());
for entry in db.entries.drain(..) {
if entry.content.trim().is_empty() {
removed_empty += 1;
continue;
}
if cutoff.is_some_and(|cutoff| entry_is_older_than(&entry, cutoff)) {
removed_old += 1;
continue;
}
if !seen.insert(entry.clone()) {
removed_duplicates += 1;
continue;
}
cleaned.push(entry);
}
db.entries = cleaned;
storage::save_db(config, &db)?;
println!("Removed duplicates: {}", removed_duplicates);
println!("Removed empty entries: {}", removed_empty);
if older_than.is_some() {
println!("Removed old entries: {}", removed_old);
}
println!("Remaining entries: {}", db.entries.len());
println!(
"Cleaned {} entries",
original_len.saturating_sub(db.entries.len())
);
Ok(())
}
fn export_entries(config: &Config, output: String) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
let json = serde_json::to_string_pretty(&db)
.map_err(|error| DevbrainError::ParseError(error.to_string()))?;
fs::write(&output, json).map_err(|error| DevbrainError::IoError(error.to_string()))?;
println!("Exported to {}", output);
Ok(())
}
fn import_entries(config: &Config, input: String, merge: bool) -> Result<(), DevbrainError> {
validate_input_file(&input)?;
let contents =
fs::read_to_string(&input).map_err(|error| DevbrainError::IoError(error.to_string()))?;
let imported: Database = serde_json::from_str(&contents).map_err(|error| {
DevbrainError::ParseError(format!(
"Import file {} is not valid devbrain JSON: {}",
input, error
))
})?;
if merge {
let mut current = storage::load_db(config)?;
let mut added = 0;
let mut seen: HashSet<u64> = current.entries.iter().map(entry_fingerprint).collect();
for entry in imported.entries {
if seen.insert(entry_fingerprint(&entry)) {
current.entries.push(entry);
added += 1;
}
}
storage::save_db(config, ¤t)?;
println!("Imported successfully ({} new entries added)", added);
} else {
storage::save_db(config, &imported)?;
println!("Data imported successfully (replaced existing data)");
}
Ok(())
}
fn show_stats(config: &Config) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
if db.entries.is_empty() {
println!("No data available");
return Ok(());
}
let mut log_count = 0;
let mut command_count = 0;
let mut error_count = 0;
let mut project_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
for entry in &db.entries {
match entry.entry_type {
EntryType::Log => log_count += 1,
EntryType::Command => command_count += 1,
EntryType::Error => error_count += 1,
}
*project_counts.entry(entry.project.clone()).or_insert(0) += 1;
for tag in &entry.tags {
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
}
}
let total_entries = db.entries.len();
let top_project = project_counts.iter().max_by_key(|(_, count)| *count);
let mut top_tags: Vec<(&String, &usize)> = tag_counts.iter().collect();
top_tags.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
println!("----------------------------------------");
println!("Total entries: {}", total_entries);
println!();
println!("Logs: {}", log_count);
println!("Commands: {}", command_count);
println!("Errors: {}", error_count);
println!();
if let Some((project, count)) = top_project {
println!("Top project: {} ({} entries)", project, count);
}
if !top_tags.is_empty() {
println!();
println!("Top tags:");
println!();
for (tag, count) in top_tags.into_iter().take(5) {
println!("{}: {}", tag, count);
}
}
println!("----------------------------------------");
Ok(())
}
fn show_project_summary(config: &Config) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
let project = utils::get_project_name();
let mut total_entries = 0;
let mut command_count = 0;
let mut error_count = 0;
let mut tag_counts: HashMap<String, usize> = HashMap::new();
for entry in &db.entries {
if entry.project != project {
continue;
}
total_entries += 1;
match entry.entry_type {
EntryType::Log => {}
EntryType::Command => command_count += 1,
EntryType::Error => error_count += 1,
}
for tag in &entry.tags {
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
}
}
println!("Project: {}", project);
println!();
println!("Entries: {}", total_entries);
println!("Errors: {}", error_count);
println!("Commands: {}", command_count);
if !tag_counts.is_empty() {
let mut top_tags: Vec<(&String, &usize)> = tag_counts.iter().collect();
top_tags.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
println!();
println!("Top tags:");
for (tag, count) in top_tags.into_iter().take(5) {
println!("{}: {}", tag, count);
}
}
Ok(())
}
fn show_debug_view(config: &Config, display_options: DisplayOptions) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
let project = utils::get_project_name();
let mut entries: Vec<&Entry> = db
.entries
.iter()
.filter(|entry| entry.project == project)
.filter(|entry| {
matches!(
entry.entry_type,
EntryType::Command | EntryType::Error | EntryType::Log
)
})
.collect();
entries.sort_by(|a, b| {
debug_priority(a)
.cmp(&debug_priority(b))
.then_with(|| b.timestamp.cmp(&a.timestamp))
});
let entries: Vec<&Entry> = entries.into_iter().take(10).collect();
if entries.is_empty() {
println!("No entries found");
return Ok(());
}
print_entries(&entries, &db.entries, false, None, display_options)?;
Ok(())
}
fn handle_preset_command(
config: &Config,
display_options: DisplayOptions,
args: &[String],
) -> Result<(), DevbrainError> {
match args {
[] => Err(DevbrainError::Other(
"Use: preset <name> or preset save <name> \"search ...\"".to_string(),
)),
[name] => run_preset(config, display_options, name),
[action, name, query] if action == "save" => save_custom_preset(config, name, query),
_ => Err(DevbrainError::Other(
"Use: preset <name> or preset save <name> \"search ...\"".to_string(),
)),
}
}
fn run_preset(
config: &Config,
display_options: DisplayOptions,
name: &str,
) -> Result<(), DevbrainError> {
let preset = if let Some(preset) = built_in_preset(name) {
preset
} else {
let presets = load_custom_presets(config)?;
let query = presets.get(name).ok_or_else(|| {
DevbrainError::Other(
"Unknown preset. Use: recent-errors, recent-commands, debug, or save your own"
.to_string(),
)
})?;
parse_preset_query(query)?
};
let project = project_filter(false);
let since_cutoff = parse_since_cutoff(preset.since.as_deref())?;
let db = storage::load_db(config)?;
let filters = EntryFilters {
query: None,
project: project.as_deref(),
entry_type: preset.entry_type.as_ref(),
session_id: None,
success: None,
since: since_cutoff.as_deref(),
offset: None,
limit: preset.limit,
};
let entries = process_entries(&db.entries, &filters);
if entries.is_empty() {
println!("No entries found");
return Ok(());
}
print_entries(&entries, &db.entries, false, None, display_options)?;
Ok(())
}
fn show_command_suggestions(config: &Config, query: &str) -> Result<(), DevbrainError> {
let db = storage::load_db(config)?;
let project = utils::get_project_name();
let query = query.to_lowercase();
let mut counts: HashMap<String, usize> = HashMap::new();
for entry in &db.entries {
if entry.project != project || entry.entry_type != EntryType::Command {
continue;
}
if entry.content.to_lowercase().contains(&query) {
*counts.entry(entry.content.clone()).or_insert(0) += 1;
}
}
if counts.is_empty() {
println!("No matching commands found");
return Ok(());
}
let mut suggestions: Vec<(String, usize)> = counts.into_iter().collect();
suggestions.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (command, _count) in suggestions.into_iter().take(10) {
println!("{}", command);
}
Ok(())
}
fn save_custom_preset(config: &Config, name: &str, query: &str) -> Result<(), DevbrainError> {
parse_preset_query(query)?;
if let Some(parent) = config.presets_path.parent() {
fs::create_dir_all(parent).map_err(|error| DevbrainError::IoError(error.to_string()))?;
}
let mut presets = load_custom_presets(config)?;
presets.insert(name.to_string(), query.to_string());
let json = serde_json::to_string_pretty(&presets)
.map_err(|error| DevbrainError::ParseError(error.to_string()))?;
fs::write(&config.presets_path, json)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
println!("Saved preset {}", name);
Ok(())
}
fn confirm_clear() -> Result<bool, DevbrainError> {
print!("Are you sure you want to delete all entries? (y/n): ");
io::stdout()
.flush()
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
fn project_filter(all: bool) -> Option<String> {
if all {
None
} else {
Some(utils::get_project_name())
}
}
fn debug_priority(entry: &Entry) -> u8 {
match (&entry.entry_type, entry.success) {
(EntryType::Command, Some(false)) => 0,
(EntryType::Error, _) => 1,
(EntryType::Log, _) => 2,
(EntryType::Command, _) => 3,
}
}
struct PresetConfig {
entry_type: Option<EntryType>,
since: Option<String>,
limit: Option<usize>,
}
fn built_in_preset(name: &str) -> Option<PresetConfig> {
match name {
"recent-errors" => Some(PresetConfig {
entry_type: Some(EntryType::Error),
since: Some("1d".to_string()),
limit: None,
}),
"recent-commands" => Some(PresetConfig {
entry_type: Some(EntryType::Command),
since: None,
limit: Some(10),
}),
"debug" => Some(PresetConfig {
entry_type: Some(EntryType::Error),
since: Some("1h".to_string()),
limit: None,
}),
_ => None,
}
}
fn load_custom_presets(config: &Config) -> Result<HashMap<String, String>, DevbrainError> {
if !config.presets_path.exists() {
return Ok(HashMap::new());
}
let contents = fs::read_to_string(&config.presets_path)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let presets: HashMap<String, String> = serde_json::from_str(&contents).map_err(|error| {
DevbrainError::ParseError(format!(
"Preset file {} is corrupted or invalid JSON: {}",
config.presets_path.display(),
error
))
})?;
Ok(presets)
}
fn parse_preset_query(query: &str) -> Result<PresetConfig, DevbrainError> {
let parts: Vec<&str> = query.split_whitespace().collect();
if parts.is_empty() || parts[0] != "search" {
return Err(DevbrainError::Other(
"Preset query must start with: search".to_string(),
));
}
let mut entry_type = None;
let mut since = None;
let mut limit = None;
let mut index = 1;
while index < parts.len() {
match parts[index] {
"--type" => {
let value = parts.get(index + 1).ok_or_else(|| {
DevbrainError::Other("Missing value for --type in preset query".to_string())
})?;
entry_type = Some(EntryType::from_str(value)?);
index += 2;
}
"--since" => {
let value = parts.get(index + 1).ok_or_else(|| {
DevbrainError::Other("Missing value for --since in preset query".to_string())
})?;
parse_duration(value)?;
since = Some((*value).to_string());
index += 2;
}
"--limit" => {
let value = parts.get(index + 1).ok_or_else(|| {
DevbrainError::Other("Missing value for --limit in preset query".to_string())
})?;
limit = Some(value.parse::<usize>().map_err(|_| {
DevbrainError::Other("Invalid value for --limit in preset query".to_string())
})?);
index += 2;
}
token if token.starts_with("--") => {
return Err(DevbrainError::Other(format!(
"Unsupported preset option: {}",
token
)));
}
_ => {
index += 1;
}
}
}
Ok(PresetConfig {
entry_type,
since,
limit,
})
}
fn validate_entry_type(entry_type: Option<String>) -> Result<Option<EntryType>, DevbrainError> {
match entry_type {
Some(entry_type) => Ok(Some(EntryType::from_str(&entry_type)?)),
None => Ok(None),
}
}
fn validate_success_filter(success: bool, failed: bool) -> Result<Option<bool>, DevbrainError> {
match (success, failed) {
(true, true) => Err(DevbrainError::Other(
"Use only one of --success or --failed".to_string(),
)),
(true, false) => Ok(Some(true)),
(false, true) => Ok(Some(false)),
(false, false) => Ok(None),
}
}
fn resolve_recent_limit(limit: Option<usize>, recent: bool) -> Option<usize> {
if recent {
limit.or(Some(5))
} else {
limit
}
}
fn parse_since_cutoff(since: Option<&str>) -> Result<Option<String>, DevbrainError> {
let Some(since) = since else {
return Ok(None);
};
let duration = parse_duration(since)?;
let cutoff = (Utc::now() - duration).to_rfc3339_opts(SecondsFormat::Secs, true);
Ok(Some(cutoff))
}
fn parse_duration(value: &str) -> Result<Duration, DevbrainError> {
if value.len() < 2 {
return Err(DevbrainError::Other(
"Invalid --since value. Use formats like 1h, 1d, 7d".to_string(),
));
}
let (amount, unit) = value.split_at(value.len() - 1);
let amount: i64 = amount.parse().map_err(|_| {
DevbrainError::Other("Invalid --since value. Use formats like 1h, 1d, 7d".to_string())
})?;
if amount < 0 {
return Err(DevbrainError::Other(
"Invalid --since value. Use formats like 1h, 1d, 7d".to_string(),
));
}
match unit {
"h" => Ok(Duration::hours(amount)),
"d" => Ok(Duration::days(amount)),
_ => Err(DevbrainError::Other(
"Invalid --since value. Use formats like 1h, 1d, 7d".to_string(),
)),
}
}
fn validate_cli_args(command: &Commands) -> Result<(), DevbrainError> {
match command {
Commands::Search {
query,
limit,
offset,
since,
success,
failed,
..
} => {
validate_non_empty_value("query", query)?;
validate_limit(*limit)?;
validate_offset(*offset)?;
validate_success_filter(*success, *failed)?;
if let Some(since) = since.as_deref() {
parse_duration(since)?;
}
}
Commands::Timeline {
limit,
offset,
since,
entry_type,
..
} => {
validate_limit(*limit)?;
validate_offset(*offset)?;
if let Some(entry_type) = entry_type.clone() {
validate_entry_type(Some(entry_type))?;
}
if let Some(since) = since.as_deref() {
parse_duration(since)?;
}
}
Commands::Errors { limit, since, .. } | Commands::CmdHistory { limit, since, .. } => {
validate_limit(*limit)?;
if let Some(since) = since.as_deref() {
parse_duration(since)?;
}
}
Commands::Session { limit, .. } => validate_limit(*limit)?,
Commands::Cleanup { older_than } => {
if older_than.is_some_and(|days| days < 0) {
return Err(DevbrainError::ValidationError(
"--older-than must be 0 or greater".to_string(),
));
}
}
Commands::Export { output } => validate_output_path(output)?,
Commands::Import { input, .. } => validate_input_file(input)?,
Commands::Suggest { query } => validate_non_empty_value("query", query)?,
Commands::Log { message, .. } => validate_non_empty_value("message", message)?,
Commands::LogCmd { command, .. } | Commands::ShellLog { command, .. } => {
validate_non_empty_value("command", command)?
}
Commands::LogError { error, .. } => validate_non_empty_value("error", error)?,
Commands::Preset { args } => {
if args.is_empty() {
return Err(DevbrainError::ValidationError(
"Use: preset <name> or preset save <name> \"search ...\"".to_string(),
));
}
}
Commands::Explore
| Commands::Doctor
| Commands::Stats
| Commands::Project
| Commands::Debug
| Commands::Last { .. }
| Commands::Clear { .. } => {}
}
Ok(())
}
fn validate_limit(limit: Option<usize>) -> Result<(), DevbrainError> {
if matches!(limit, Some(0)) {
return Err(DevbrainError::ValidationError(
"--limit must be greater than 0".to_string(),
));
}
Ok(())
}
fn validate_offset(offset: Option<usize>) -> Result<(), DevbrainError> {
if matches!(offset, Some(usize::MAX)) {
return Err(DevbrainError::ValidationError(
"--offset is too large".to_string(),
));
}
Ok(())
}
fn validate_non_empty_value(label: &str, value: &str) -> Result<(), DevbrainError> {
if value.trim().is_empty() {
return Err(DevbrainError::ValidationError(format!(
"{label} cannot be empty"
)));
}
Ok(())
}
fn validate_input_file(path: &str) -> Result<(), DevbrainError> {
validate_non_empty_value("input path", path)?;
let input = Path::new(path);
if !input.exists() {
return Err(DevbrainError::ValidationError(format!(
"Input file not found: {}",
input.display()
)));
}
if !input.is_file() {
return Err(DevbrainError::ValidationError(format!(
"Input path is not a file: {}",
input.display()
)));
}
Ok(())
}
fn validate_output_path(path: &str) -> Result<(), DevbrainError> {
validate_non_empty_value("output path", path)?;
let output = Path::new(path);
if output.is_dir() {
return Err(DevbrainError::ValidationError(format!(
"Output path is a directory: {}",
output.display()
)));
}
if let Some(parent) = output.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(DevbrainError::ValidationError(format!(
"Output directory does not exist: {}",
parent.display()
)));
}
}
Ok(())
}
fn entry_is_older_than(entry: &Entry, cutoff: DateTime<Utc>) -> bool {
DateTime::parse_from_rfc3339(&entry.timestamp)
.map(|timestamp| timestamp.with_timezone(&Utc) < cutoff)
.unwrap_or(false)
}
fn entry_fingerprint(entry: &Entry) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
entry.hash(&mut hasher);
hasher.finish()
}
fn search_entries_in_db(db: &Database, opts: DatabaseSearchOptions) -> Vec<&Entry> {
let DatabaseSearchOptions {
query,
project,
entry_type,
tags: _tags,
session_id,
success,
since,
offset,
limit,
} = opts;
let filters = EntryFilters {
query: Some(&query),
project: project.as_deref(),
entry_type: entry_type.as_ref(),
session_id: session_id.as_deref(),
success,
since: since.as_deref(),
offset,
limit,
};
process_entries(&db.entries, &filters)
}
fn current_session_filter(enabled: bool) -> Result<Option<String>, DevbrainError> {
if !enabled {
return Ok(None);
}
std::env::var("DEVBRAIN_SESSION_ID")
.map(Some)
.map_err(|_| DevbrainError::Other("DEVBRAIN_SESSION_ID is not set".to_string()))
}
fn print_entries(
entries: &[&Entry],
all_entries: &[Entry],
json: bool,
terms: Option<&[String]>,
display_options: DisplayOptions,
) -> Result<(), DevbrainError> {
if json {
let json = serde_json::to_string(entries)
.map_err(|error| DevbrainError::ParseError(error.to_string()))?;
println!("{json}");
return Ok(());
}
for entry in entries {
let related = related_entries(all_entries, entry);
print_entry(entry, terms, &related, display_options);
}
Ok(())
}
fn print_entry_output(
entry: Option<&Entry>,
all_entries: &[Entry],
json: bool,
terms: Option<&[String]>,
display_options: DisplayOptions,
) -> Result<(), DevbrainError> {
if json {
let json = serde_json::to_string(&entry)
.map_err(|error| DevbrainError::ParseError(error.to_string()))?;
println!("{json}");
return Ok(());
}
if let Some(entry) = entry {
let related = related_entries(all_entries, entry);
print_entry(entry, terms, &related, display_options);
}
Ok(())
}
fn related_entries<'a>(all_entries: &'a [Entry], entry: &Entry) -> Vec<&'a Entry> {
let base_terms = parse_terms(&entry.content).unwrap_or_default();
let mut related: Vec<(&Entry, i32)> = Vec::with_capacity(3);
for candidate in all_entries
.iter()
.filter(|candidate| candidate != &entry)
.filter(|candidate| candidate.project == entry.project)
{
let mut score = 0;
for term in &base_terms {
score += query::fuzzy_match(&candidate.content, term);
}
for tag in &entry.tags {
if candidate
.tags
.iter()
.any(|candidate_tag| candidate_tag == tag)
{
score += 3;
}
}
if score > 0 {
related.push((candidate, score));
}
}
related.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.0.timestamp.cmp(&a.0.timestamp))
});
related
.into_iter()
.take(3)
.map(|(entry, _)| entry)
.collect()
}