1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! `.search` command — full-text search across session content.
use core::fmt::Write as FmtWrite;
use unilang::{ VerifiedCommand, ExecutionContext, OutputData, ErrorData, ErrorCode };
use super::storage::{ create_storage, load_project_for_param, find_session_mut };
/// Search session content for query string
///
/// Performs full-text search through session content with optional filtering.
///
/// # Errors
///
/// Returns error if query is missing, entry type is invalid, storage creation
/// fails, project loading fails, or search fails.
#[ allow( clippy::too_many_lines ) ]
// CLI routine handler processes multiple scope branches —
// extraction would obscure the command's logic without reducing complexity.
#[ allow( clippy::needless_pass_by_value ) ]
#[ inline ]
pub fn search_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let query_raw = cmd.get_string( "query" )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "query is required".to_string() ) )?;
// Fix(issue-030): Reject whitespace-only query values.
//
// Root cause: cli_main.rs quotes argv values containing spaces before joining into the
// REPL command line, so `query:: ` (spaces only) becomes `query::" "`. The REPL
// parser preserves the 3-space string (non-empty), so `ok_or_else` alone no longer
// catches whitespace-only input.
//
// Pitfall: Always trim-validate string parameters with a "must be non-empty" constraint.
// `is_some()` and `!is_empty()` are insufficient — `" ".is_empty()` is false.
let query = query_raw.trim();
if query.is_empty()
{
return Err( ErrorData::new( ErrorCode::InternalError, "query must be non-empty".to_string() ) );
}
let project_id = cmd.get_string( "project" );
let session_id = cmd.get_string( "session" );
let case_sensitive = cmd.get_boolean( "case_sensitive" ).unwrap_or( false );
let entry_type = cmd.get_string( "entry_type" );
// Validate verbosity range (0-5); reject values outside range
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" ),
) );
}
let _ = verbosity; // validated; output format unchanged between valid levels
// Create storage instance
let storage = create_storage()?;
// Build search filter
let mut filter = claude_storage_core::SearchFilter::new( query )
.case_sensitive( case_sensitive );
// Add entry type filter if specified
//
// Fix(issue-021): Handle "all" as a valid entry_type value
//
// Root cause: Only "user" and "assistant" were handled in the match; "all" fell
// through to the error arm despite the YAML spec documenting it as valid
// ("Filter by entry type (user, assistant, or all)").
//
// Pitfall: Enumerated parameter match arms must cover every value listed in the
// YAML spec description. Check the YAML spec when adding match arms, not just
// what you remember implementing.
if let Some( et ) = entry_type
{
match et
{
"user" => filter = filter.match_entry_type( claude_storage_core::EntryType::User ),
"assistant" => filter = filter.match_entry_type( claude_storage_core::EntryType::Assistant ),
"all" => { /* no type filter — same as omitting entry_type */ }
_ => return Err( ErrorData::new( ErrorCode::InternalError, format!( "Invalid entry_type: {et}. Valid values: user, assistant, all" ) ) ),
}
}
// Determine search scope
let mut all_matches = Vec::new();
if let Some( sess_id ) = session_id
{
// Search specific session
let project = if let Some( proj_id ) = project_id
{
// Fix(issue-012): Support path projects in .search command
//
// Root cause: Hardcoded ProjectId::uuid() prevented path projects from working.
// Commands .count/.search/.export shared this bug which was fixed for .show (Finding #008)
// but not propagated.
//
// Pitfall: When fixing a bug in one command, grep for identical patterns in other commands.
// Bugs often exist in multiple locations sharing the same flawed assumption.
load_project_for_param( &storage, proj_id )
}
else
{
storage.load_project_for_cwd()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )
}?;
let mut sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
// Fix(issue-020): Use prefix matching for partial UUID, consistent with show_routine
// and export_routine (issue-011 fix).
//
// Root cause: search_routine used exact equality only, so ".search session::79f86582"
// failed even though ".show session_id::79f86582" succeeds via starts_with.
//
// Pitfall: Partial-UUID support must be applied uniformly. Any session find()
// predicate that uses only == will silently reject valid prefix IDs.
let session = find_session_mut( &mut sessions, sess_id )?;
let matches = session.search( &filter )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Search failed: {e}" ) ) )?;
for m in matches
{
all_matches.push( ( project.id().clone(), sess_id.to_string(), m ) );
}
}
else if let Some( proj_id ) = project_id
{
// Search specific project
// Fix(issue-012): Support path projects in .search command
//
// Root cause: Hardcoded ProjectId::uuid() prevented path projects from working.
// Commands .count/.search/.export shared this bug which was fixed for .show (Finding #008)
// but not propagated.
//
// Pitfall: When fixing a bug in one command, grep for identical patterns in other commands.
// Bugs often exist in multiple locations sharing the same flawed assumption.
let project = load_project_for_param( &storage, proj_id )?;
let mut sessions = project.sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
for session in &mut sessions
{
let matches = match session.search( &filter )
{
Ok( m ) => m,
Err( e ) => { eprintln!( "warning: search skipped session {}: {e}", session.id() ); continue; }
};
for m in matches
{
all_matches.push( ( project.id().clone(), session.id().to_string(), m ) );
}
}
}
else
{
// No project or session specified: search all projects globally
let projects = storage.list_projects()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list projects: {e}" ) ) )?;
for project in &projects
{
let mut sessions = project.sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions for {:?}: {e}", project.id() ) ) )?;
for session in &mut sessions
{
let matches = match session.search( &filter )
{
Ok( m ) => m,
Err( e ) => { eprintln!( "warning: search skipped session {}: {e}", session.id() ); continue; }
};
for m in matches
{
all_matches.push( ( project.id().clone(), session.id().to_string(), m ) );
}
}
}
}
// Format output
let mut output = String::new();
let noun = if all_matches.len() == 1 { "match" } else { "matches" };
writeln!( output, "Found {} {noun}:\n", all_matches.len() ).unwrap();
for ( _proj_id, sess_id, m ) in &all_matches
{
// Standard: session + excerpt
writeln!
(
output,
"[{}] [{:?}] {}",
sess_id,
m.entry_type(),
m.excerpt()
).unwrap();
}
if all_matches.is_empty()
{
output.push_str( "No matches found.\n" );
}
Ok( OutputData::new( output, "text" ) )
}