use std::path::PathBuf;
use anyhow::Context;
use crate::app::config::{self, Config};
use crate::app::daemon::{
DaemonRequest, DaemonRequestPayload, GroundRequest, GroundResult, client_for,
};
use crate::domain::common::expand_tilde;
use crate::domain::ground::{Format, GroundResponse, RenderOpts, render};
const DEFAULT_LIMIT: usize = 50;
#[derive(Debug, Default, Clone)]
pub struct GroundArgs {
pub query: String,
pub corpus: Option<String>,
pub format: Format,
pub snippet_chars: Option<usize>,
pub top_files: Option<usize>,
pub chunks_per_file: Option<usize>,
pub limit: Option<usize>,
pub config: Option<PathBuf>,
pub socket: Option<PathBuf>,
}
pub async fn cmd_ground(args: GroundArgs) -> anyhow::Result<()> {
let format = args.format;
let snippet_chars = args.snippet_chars;
let path_prefix_strip = resolve_effective_prefix_strip(&args);
let response = run_ground(args).await?;
let opts = RenderOpts {
snippet_chars,
path_prefix_strip,
};
println!("{}", render(&response, format, &opts));
Ok(())
}
fn resolve_effective_prefix_strip(args: &GroundArgs) -> Option<String> {
let baseline = config::load_xdg(args.config.as_deref()).ok()?;
let cwd = std::env::current_dir().ok()?;
let (effective, _layers) = config::resolve_for_cwd(&baseline, &cwd, None).ok()?;
resolve_path_prefix_strip(&effective, args.corpus.as_deref())
}
fn resolve_path_prefix_strip(cfg: &Config, requested_corpus: Option<&str>) -> Option<String> {
let effective = cfg.effective_corpora().ok()?;
let candidate = match requested_corpus {
Some(name) => effective.iter().find(|c| c.name == name),
None if effective.len() == 1 => effective.first(),
_ => None,
};
let corpus = candidate?;
if corpus.paths.len() != 1 {
return None;
}
let expanded = expand_tilde(&corpus.paths[0]);
let mut prefix = expanded.to_string_lossy().into_owned();
if !prefix.ends_with('/') {
prefix.push('/');
}
Some(prefix)
}
pub async fn run_ground(args: GroundArgs) -> anyhow::Result<GroundResponse> {
let client = client_for(args.socket.as_deref()).await?;
let cwd = std::env::current_dir().context("capture current_dir for daemon request")?;
let req = DaemonRequest {
cwd,
payload: DaemonRequestPayload::Ground(GroundRequest {
query: args.query,
corpus: args.corpus,
top_files: args.top_files,
chunks_per_file: args.chunks_per_file,
limit: args.limit.or(Some(DEFAULT_LIMIT)),
snippet_chars: args.snippet_chars,
}),
};
let result: GroundResult = client.call(req).await?;
Ok(result.response)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::common::CorpusConfig;
use crate::domain::repository::RepositoryConfig;
#[test]
fn path_prefix_strip_returns_sole_corpus_path_with_trailing_slash() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "only".into(),
paths: vec!["/abs/cheese".into()],
..Default::default()
}],
..Default::default()
};
assert_eq!(
resolve_path_prefix_strip(&cfg, None),
Some("/abs/cheese/".to_string())
);
}
#[test]
fn path_prefix_strip_preserves_existing_trailing_slash() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "only".into(),
paths: vec!["/abs/cheese/".into()],
..Default::default()
}],
..Default::default()
};
assert_eq!(
resolve_path_prefix_strip(&cfg, None),
Some("/abs/cheese/".to_string()),
"single trailing slash, not two"
);
}
#[test]
fn path_prefix_strip_returns_none_for_multi_path_corpus() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "only".into(),
paths: vec!["/a".into(), "/b".into()],
..Default::default()
}],
..Default::default()
};
assert_eq!(resolve_path_prefix_strip(&cfg, None), None);
}
#[test]
fn path_prefix_strip_returns_none_for_multi_corpus_without_filter() {
let cfg = Config {
corpora: vec![
CorpusConfig {
name: "a".into(),
paths: vec!["/a".into()],
..Default::default()
},
CorpusConfig {
name: "b".into(),
paths: vec!["/b".into()],
..Default::default()
},
],
..Default::default()
};
assert_eq!(resolve_path_prefix_strip(&cfg, None), None);
}
#[test]
fn path_prefix_strip_uses_named_corpus_in_multi_corpus_config() {
let cfg = Config {
corpora: vec![
CorpusConfig {
name: "a".into(),
paths: vec!["/a".into()],
..Default::default()
},
CorpusConfig {
name: "b".into(),
paths: vec!["/b".into()],
..Default::default()
},
],
..Default::default()
};
assert_eq!(
resolve_path_prefix_strip(&cfg, Some("b")),
Some("/b/".to_string()),
);
}
#[test]
fn path_prefix_strip_returns_none_when_named_corpus_missing() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "a".into(),
paths: vec!["/a".into()],
..Default::default()
}],
..Default::default()
};
assert_eq!(resolve_path_prefix_strip(&cfg, Some("ghost")), None);
}
#[test]
fn path_prefix_strip_expands_tilde_in_corpus_path() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "cheese".into(),
paths: vec!["~/.cheese".into()],
..Default::default()
}],
..Default::default()
};
let prefix = resolve_path_prefix_strip(&cfg, None).expect("strip resolved");
assert!(
!prefix.starts_with('~'),
"tilde must be expanded: got {prefix:?}"
);
assert!(prefix.ends_with("/.cheese/"), "trailing slash: {prefix:?}");
}
#[test]
fn path_prefix_strip_resolves_against_single_repository_wiki() {
let cfg = Config {
repositories: vec![RepositoryConfig {
name: "tern".into(),
path: "/repos/tern".into(),
..Default::default()
}],
..Default::default()
};
assert_eq!(
resolve_path_prefix_strip(&cfg, None),
Some("/repos/tern/.hallouminate/wiki/".to_string())
);
}
}