use super::*;
fn emit_advisory_notes(
args: &SearchArgs,
matcher: &Matcher,
has_turn_range: bool,
time_window: &TimeWindow,
has_session_filter: bool,
) {
if path::is_uuid(&args.pattern) && !has_session_filter {
eprintln!(
"csift: note: searching for this uuid as TEXT across the scope; to scope the \
search TO that session, pass it as a target: `csift search <PATTERN> @{}`",
args.pattern
);
}
if matcher.is_pure_filter()
&& args.labels.is_empty()
&& args.labels_not.is_empty()
&& !has_turn_range
&& time_window.is_unbounded()
&& !has_session_filter
{
eprintln!(
"csift: warning: empty pattern with no category/time/turn/session filter \
matches every exchange in scope — this may emit a lot."
);
}
}
fn emit_count_only(outcome: &SearchOutcome, format: OutputFormat) -> Result<()> {
let total = outcome.exchanges.len() + outcome.dropped_by_cap;
match format {
OutputFormat::Text => println!("{total}"),
OutputFormat::Json => {
let header = crate::text::envelope_scope_header(
"search",
outcome.scope_top,
outcome.scope_sub,
serde_json::json!({}),
);
println!("{}", serde_json::to_string(&header)?);
let summary = crate::text::envelope_summary(serde_json::json!({ "matched": total }));
println!("{}", serde_json::to_string(&summary)?);
}
}
Ok(())
}
fn emit_sessions_with_matches(outcome: &SearchOutcome, format: OutputFormat) -> Result<()> {
if format == OutputFormat::Json {
bail!("-l prints a plain id stream; with --format json read the summary's `transcript_ids` instead");
}
let mut ids: Vec<&str> = outcome
.exchanges
.iter()
.map(|e| e.parent_session_id.as_str())
.collect();
ids.sort_unstable();
ids.dedup();
for id in &ids {
println!("{id}");
}
if outcome.dropped_by_cap > 0 {
eprintln!(
"csift: note: {} exchange(s) dropped by --max-count — this session listing \
may be incomplete; raise --max-count",
outcome.dropped_by_cap
);
}
Ok(())
}
pub fn run_search(args: &SearchArgs) -> Result<()> {
if args.pattern.starts_with('@') {
bail!(
"search's FIRST positional is the regex PATTERN — targets come AFTER it: \
`csift search <PATTERN> {0}`. To literally match '{0}', escape the @: '\\{0}'.",
args.pattern
);
}
let turn_range = args
.turn_range
.as_deref()
.map(parse_turn_range)
.transpose()?;
let time_window = TimeWindow::from_args(args.since.as_deref(), args.until.as_deref())?;
let has_session_filter = args.sessions_from.is_some()
|| args
.targets()
.iter()
.filter_map(|p| p.to_str())
.any(path::pins_single_session);
if args.label_filter().is_statically_empty() {
bail!(
"-T excludes every label the -t selection includes (-t {:?} -T {:?}) — this \
filter can never match anything. Loosen -T or widen -t.",
args.labels,
args.labels_not
);
}
let matcher = build_matcher(args)?;
let session_files = path::resolve_targets_with_session_list(
&args.targets(),
args.sessions_from.as_deref(),
args.want_subagents().into(),
path::Caller::Other,
)?;
emit_advisory_notes(
args,
&matcher,
turn_range.is_some(),
&time_window,
has_session_filter,
);
let want_siblings = args.siblings;
let mut spawn_map: HashMap<PathBuf, Option<Arc<DiscoveredSpawns>>> = HashMap::new();
for p in &session_files {
spawn_map
.entry(discovery_root_for(p))
.or_insert_with_key(|root| build_spawn_lookup(root).map(Arc::new));
}
let inner_parallel = session_files.len() <= rayon::current_num_threads() * 2;
let per_file: Vec<FileResult> = session_files
.par_iter()
.map(|p| {
search_one_file(
p,
args,
&matcher,
turn_range,
&time_window,
None,
want_siblings,
&spawn_map,
inner_parallel,
)
})
.collect::<Result<Vec<_>>>()?;
let scope_sub = session_files
.iter()
.filter(|p| crate::subagent::is_subagent_path(p))
.count();
let scope_top = session_files.len() - scope_sub;
let mut outcome = SearchOutcome {
scope_top,
scope_sub,
..SearchOutcome::default()
};
let mut all: Vec<Exchange> = Vec::new();
for fr in per_file {
outcome.skipped_lines += fr.skipped_lines;
outcome.superseded_drafts += fr.superseded_drafts;
all.extend(fr.exchanges);
}
all.sort_by(|a, b| {
timestamp_sort_key(a.started_utc.as_deref())
.cmp(×tamp_sort_key(b.started_utc.as_deref()))
});
outcome.total_matched = all.len();
outcome.total_sessions = distinct_session_count(&all);
if let Some(cap) = args.max_count.filter(|&n| n != 0) {
let keep = usize::try_from(cap.unsigned_abs()).unwrap_or(usize::MAX);
if all.len() > keep {
outcome.dropped_by_cap = all.len() - keep;
if cap > 0 {
all.truncate(keep);
} else {
all.drain(..all.len() - keep);
}
}
}
outcome.exchanges = all;
if args.count_only {
return emit_count_only(&outcome, args.format);
}
if args.sessions_with_matches {
return emit_sessions_with_matches(&outcome, args.format);
}
if let Some(axis) = args.count_by {
let (rows, records, excluded) = axis_census(
&outcome.exchanges,
axis,
LabelFilter::new(&args.labels, &args.labels_not),
);
let slug = axis.slug();
match args.format {
OutputFormat::Text => {
for (key, n) in &rows {
println!("{n:>7} {key}");
}
let excl = if excluded > 0 {
format!(" · {excluded} record(s) have no {slug} (outside this axis)")
} else {
String::new()
};
let drop = if outcome.dropped_by_cap > 0 {
format!(
" · {} exchange(s) dropped by --max-count (census incomplete; raise --max-count)",
outcome.dropped_by_cap
)
} else {
String::new()
};
eprintln!(
"csift: {records} matched record(s) across {} {slug} key(s){excl}{drop}",
rows.len()
);
}
OutputFormat::Json => {
let header = crate::text::envelope_scope_header(
"search",
outcome.scope_top,
outcome.scope_sub,
serde_json::json!({}),
);
println!("{}", serde_json::to_string(&header)?);
for (key, n) in &rows {
let obj = serde_json::json!({
"kind": "census",
"axis": slug,
"key": key,
"records": n,
});
println!("{}", serde_json::to_string(&obj)?);
}
let summary = crate::text::envelope_summary(serde_json::json!({
"axis": slug,
"matched_records": records,
"distinct_keys": rows.len(),
"excluded_records": excluded,
"dropped_by_cap": outcome.dropped_by_cap,
"skipped_lines": outcome.skipped_lines,
}));
println!("{}", serde_json::to_string(&summary)?);
}
}
return Ok(());
}
if args.raw {
if args.format == OutputFormat::Json {
bail!("--raw IS the machine output (verbatim jsonl lines) — drop --format json");
}
let mut skipped_sidecar = 0usize;
let mut seen: std::collections::BTreeSet<(&str, usize)> = std::collections::BTreeSet::new();
for ex in &outcome.exchanges {
for h in &ex.hits {
if h.from_sidecar {
skipped_sidecar += 1;
continue;
}
if let Some(raw) = &h.raw {
if seen.insert((ex.session_id.as_str(), h.line)) {
println!("{raw}");
}
}
}
}
if skipped_sidecar > 0 {
eprintln!(
"csift: note: {skipped_sidecar} sidecar-merged record(s) have no physical \
transcript line — omitted under --raw"
);
}
if outcome.dropped_by_cap > 0 {
eprintln!(
"csift: note: {} {} exchange(s) dropped by --max-count",
outcome.dropped_by_cap,
dropped_side(args)
);
}
if outcome.skipped_lines > 0 {
eprintln!(
"csift: note: {}",
crate::text::malformed_note(outcome.skipped_lines)
);
}
return Ok(());
}
let diagnosis = if outcome.exchanges.is_empty() {
let label_filtered = !args.labels.is_empty() || !args.labels_not.is_empty();
let excluded_by_label = if label_filtered {
let mut probe = args.clone();
probe.labels.clear();
probe.labels_not.clear();
let probe_files: Vec<FileResult> = session_files
.par_iter()
.map(|p| {
search_one_file(
p,
&probe,
&matcher,
turn_range,
&time_window,
None,
false,
&spawn_map,
inner_parallel,
)
})
.collect::<Result<Vec<_>>>()?;
let mut probe_ex: Vec<Exchange> = Vec::new();
for fr in probe_files {
probe_ex.extend(fr.exchanges);
}
let (counts, recs) = label_census(&probe_ex, LabelFilter::all());
(recs > 0).then(|| {
let mut rows: Vec<(String, usize)> = counts
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
(rows, recs)
})
} else {
None
};
let diag = EmptyDiagnosis {
sessions_in_scope: outcome.scope_top + outcome.scope_sub,
active_filters: active_filters_str(args),
skipped_lines: outcome.skipped_lines,
label_filtered,
excluded_by_label,
};
emit_empty_diagnosis(&args.pattern, &diag);
Some(diag)
} else {
None
};
match args.format {
OutputFormat::Text => render_text(&outcome, args),
OutputFormat::Json => render_json(&outcome, diagnosis.as_ref())?,
}
Ok(())
}
pub(crate) fn merged_any_sidecar(exchanges: &[Exchange]) -> bool {
exchanges.iter().any(|ex| {
ex.hits
.iter()
.chain(ex.siblings.iter())
.any(|h| h.from_sidecar)
})
}
pub(crate) fn any_truncated_excerpt(exchanges: &[Exchange]) -> bool {
exchanges.iter().any(|ex| {
ex.hits
.iter()
.chain(ex.siblings.iter())
.any(|h| h.truncated)
})
}
pub(crate) fn distinct_session_count(exchanges: &[Exchange]) -> usize {
let mut seen: Vec<&str> = Vec::new();
for ex in exchanges {
if !seen.contains(&ex.session_id.as_str()) {
seen.push(&ex.session_id);
}
}
seen.len()
}
pub(crate) fn timestamp_sort_key(ts: Option<&str>) -> (bool, &str) {
match ts {
Some(t) => (false, t),
None => (true, ""),
}
}
pub(crate) struct FileResult {
pub(crate) exchanges: Vec<Exchange>,
pub(crate) skipped_lines: usize,
pub(crate) turn_count: usize,
pub(crate) superseded_drafts: usize,
}
pub(crate) struct Kept {
pub(crate) rec: Record,
pub(crate) can_hit: bool,
pub(crate) line_no: usize,
pub(crate) from_sidecar: bool,
}