use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
#[derive(Debug, Clone, ValueEnum, PartialEq, Default)]
pub enum OutputFormat {
#[default]
Table,
Json,
Markdown,
}
#[derive(Debug, Parser)]
#[command(
name = "knot-indexer",
version,
about = "Codebase Graph + Vector RAG Indexer"
)]
pub struct IndexerCli {
#[arg(long, env = "KNOT_REPO_PATH")]
pub repo_path: Option<String>,
#[arg(long, env = "KNOT_REPO_NAME")]
pub repo_name: Option<String>,
#[arg(long, env = "KNOT_QDRANT_URL", default_value = "http://localhost:6334")]
pub qdrant_url: String,
#[arg(long, env = "KNOT_QDRANT_COLLECTION", default_value = "knot_entities")]
pub qdrant_collection: String,
#[arg(long, env = "KNOT_NEO4J_URI", default_value = "bolt://localhost:7687")]
pub neo4j_uri: String,
#[arg(long, env = "KNOT_NEO4J_USER", default_value = "neo4j")]
pub neo4j_user: String,
#[arg(long, env = "KNOT_NEO4J_PASSWORD")]
pub neo4j_password: Option<String>,
#[arg(long, env = "KNOT_CUSTOM_QUERIES_PATH")]
pub custom_queries_path: Option<String>,
#[arg(long, env = "KNOT_EMBED_DIM", default_value_t = 384)]
pub embed_dim: u64,
#[arg(long, env = "KNOT_BATCH_SIZE", default_value_t = 64)]
pub batch_size: usize,
#[arg(long, env = "KNOT_CLEAN", default_value_t = false)]
pub clean: bool,
#[arg(long, env = "KNOT_DEPENDENCIES")]
pub dependencies: Option<String>,
#[arg(long, env = "KNOT_WATCH", default_value_t = false)]
pub watch: bool,
#[arg(long, env = "KNOT_CUSTOM_CA_CERTS")]
pub custom_ca_certs: Option<String>,
#[arg(long, env = "KNOT_INGEST_CONCURRENCY", default_value_t = 4)]
pub ingest_concurrency: usize,
#[arg(long, env = "KNOT_RAYON_THREADS")]
pub rayon_threads: Option<usize>,
#[arg(long, env = "KNOT_INCLUDE_CONFIG_FILES", default_value_t = false)]
pub include_config_files: bool,
}
#[derive(Debug, Parser)]
#[command(
name = "knot-mcp",
version,
about = "knot MCP Server for Codebase Semantic Search"
)]
pub struct McpCli {
#[arg(long, env = "KNOT_REPO_PATH")]
pub repo_path: Option<String>,
#[arg(long, env = "KNOT_REPO_NAME")]
pub repo_name: Option<String>,
#[arg(long, env = "KNOT_QDRANT_URL", default_value = "http://localhost:6334")]
pub qdrant_url: String,
#[arg(long, env = "KNOT_QDRANT_COLLECTION", default_value = "knot_entities")]
pub qdrant_collection: String,
#[arg(long, env = "KNOT_NEO4J_URI", default_value = "bolt://localhost:7687")]
pub neo4j_uri: String,
#[arg(long, env = "KNOT_NEO4J_USER", default_value = "neo4j")]
pub neo4j_user: String,
#[arg(long, env = "KNOT_NEO4J_PASSWORD")]
pub neo4j_password: Option<String>,
#[arg(long, env = "KNOT_EMBED_DIM", default_value_t = 384, hide = true)]
pub embed_dim: u64,
#[arg(long, env = "KNOT_DRY_RUN", default_value_t = false, hide = true)]
pub dry_run: bool,
#[arg(long, env = "KNOT_CUSTOM_CA_CERTS")]
pub custom_ca_certs: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub repo_path: String,
pub repo_name: String,
pub qdrant_url: String,
pub qdrant_collection: String,
pub neo4j_uri: String,
pub neo4j_user: String,
pub neo4j_password: String,
pub custom_queries_path: Option<String>,
pub embed_dim: u64,
pub batch_size: usize,
pub clean: bool,
pub dependency_repos: Vec<String>,
pub watch: bool,
pub dry_run: bool,
pub custom_ca_certs: Option<String>,
pub output_format: OutputFormat,
pub ingest_concurrency: usize,
pub rayon_threads: Option<usize>,
pub include_config_files: bool,
}
fn knot_env_path() -> Option<std::path::PathBuf> {
std::env::var("KNOT_CONFIG_DIR")
.ok()
.map(|d| std::path::PathBuf::from(d).join(".env"))
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|d| std::path::PathBuf::from(d).join(".config/knot/.env"))
})
.or_else(|| {
std::env::var("USERPROFILE")
.ok()
.map(|d| std::path::PathBuf::from(d).join(".config/knot/.env"))
})
}
fn load_knot_env() {
let Some(env_path) = knot_env_path() else {
tracing::debug!("No .env location found (set KNOT_CONFIG_DIR, HOME, or USERPROFILE)");
return;
};
match dotenvy::from_path(&env_path) {
Ok(_) => tracing::info!("Loaded env from {}", env_path.display()),
Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(
"No .env file at {} — using environment variables and CLI args",
env_path.display()
);
}
Err(e) => {
tracing::warn!("Failed to load .env from {}: {e}", env_path.display());
}
}
}
fn resolve_repo_path(repo_path: Option<String>) -> Result<String> {
if let Some(path) = repo_path {
Ok(std::fs::canonicalize(&path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(path))
} else {
Ok(std::env::current_dir()
.context("Failed to determine current working directory for repo_path")?
.to_string_lossy()
.into_owned())
}
}
fn resolve_repo_name(repo_name: Option<String>, repo_path: &str) -> String {
if let Some(name) = repo_name {
name
} else {
std::path::Path::new(repo_path)
.file_name()
.and_then(|n| n.to_str())
.map(String::from)
.unwrap_or_else(|| "unnamed-repo".to_string())
}
}
fn parse_dependencies(deps: Option<&String>) -> Vec<String> {
deps.map(|s| {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
impl Config {
pub fn load_indexer() -> Result<Self> {
Self::load_env_and_parse(IndexerCli::parse).map(
|(cli, repo_path, repo_name, neo4j_password)| {
let dependency_repos = parse_dependencies(cli.dependencies.as_ref());
Self {
repo_path,
repo_name,
qdrant_url: cli.qdrant_url,
qdrant_collection: cli.qdrant_collection,
neo4j_uri: cli.neo4j_uri,
neo4j_user: cli.neo4j_user,
neo4j_password,
custom_queries_path: cli.custom_queries_path,
embed_dim: cli.embed_dim,
batch_size: cli.batch_size,
clean: cli.clean,
dependency_repos,
watch: cli.watch,
dry_run: false,
custom_ca_certs: cli.custom_ca_certs,
output_format: OutputFormat::Markdown,
ingest_concurrency: cli.ingest_concurrency,
rayon_threads: cli.rayon_threads,
include_config_files: cli.include_config_files,
}
},
)
}
pub fn load_mcp() -> Result<Self> {
Self::load_env_and_parse(McpCli::parse).map(
|(cli, repo_path, repo_name, neo4j_password)| Self {
repo_path,
repo_name,
qdrant_url: cli.qdrant_url,
qdrant_collection: cli.qdrant_collection,
neo4j_uri: cli.neo4j_uri,
neo4j_user: cli.neo4j_user,
neo4j_password,
custom_queries_path: None,
embed_dim: cli.embed_dim,
batch_size: 0,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: cli.dry_run,
custom_ca_certs: cli.custom_ca_certs,
output_format: OutputFormat::Markdown,
ingest_concurrency: 4,
rayon_threads: None,
include_config_files: false,
},
)
}
pub fn load_knot_cli() -> Result<Self> {
load_knot_env();
let cli = McpCli::try_parse_from(["knot"])?;
let neo4j_password = cli.neo4j_password()
.or_else(|| std::env::var("KNOT_NEO4J_PASSWORD").ok())
.context("Neo4j password is required. Provide it via KNOT_NEO4J_PASSWORD environment variable.")?;
let repo_path = resolve_repo_path(cli.repo_path())?;
tracing::info!("Resolved repo_path: {repo_path}");
let repo_name = resolve_repo_name(cli.repo_name(), &repo_path);
Ok(Self {
repo_path,
repo_name,
qdrant_url: cli.qdrant_url,
qdrant_collection: cli.qdrant_collection,
neo4j_uri: cli.neo4j_uri,
neo4j_user: cli.neo4j_user,
neo4j_password,
custom_queries_path: None,
embed_dim: cli.embed_dim,
batch_size: 0,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: false,
custom_ca_certs: cli.custom_ca_certs,
output_format: OutputFormat::Table,
ingest_concurrency: 4,
rayon_threads: None,
include_config_files: false,
})
}
fn load_env_and_parse<T, F>(parse_cli: F) -> Result<(T, String, String, String)>
where
T: HasCommonFields,
F: Fn() -> T,
{
load_knot_env();
let cli = parse_cli();
let neo4j_password = cli.neo4j_password()
.or_else(|| std::env::var("KNOT_NEO4J_PASSWORD").ok())
.context("Neo4j password is required. Provide it via --neo4j-password or KNOT_NEO4J_PASSWORD environment variable.")?;
let repo_path = resolve_repo_path(cli.repo_path())?;
tracing::info!("Resolved repo_path: {repo_path}");
let repo_name = resolve_repo_name(cli.repo_name(), &repo_path);
Ok((cli, repo_path, repo_name, neo4j_password))
}
}
trait HasCommonFields {
fn repo_path(&self) -> Option<String>;
fn repo_name(&self) -> Option<String>;
fn neo4j_password(&self) -> Option<String>;
}
impl HasCommonFields for IndexerCli {
fn repo_path(&self) -> Option<String> {
self.repo_path.clone()
}
fn repo_name(&self) -> Option<String> {
self.repo_name.clone()
}
fn neo4j_password(&self) -> Option<String> {
self.neo4j_password.clone()
}
}
impl HasCommonFields for McpCli {
fn repo_path(&self) -> Option<String> {
self.repo_path.clone()
}
fn repo_name(&self) -> Option<String> {
self.repo_name.clone()
}
fn neo4j_password(&self) -> Option<String> {
self.neo4j_password.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_repo_name_auto_detection() {
assert_eq!(resolve_repo_name(None, "/path/to/my-project"), "my-project");
}
#[test]
fn test_repo_name_provided() {
assert_eq!(
resolve_repo_name(Some("custom-name".to_string()), "/path/to/my-project"),
"custom-name"
);
}
#[test]
fn test_indexer_cli_parsing_basic() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, Some("/tmp/repo".to_string()));
assert_eq!(cli.neo4j_password, Some("secret".to_string()));
assert_eq!(cli.qdrant_url, "http://localhost:6334"); }
#[test]
fn test_indexer_cli_parsing_full() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--repo-name",
"my-repo",
"--qdrant-url",
"http://qdrant:6334",
"--qdrant-collection",
"custom_collection",
"--neo4j-uri",
"bolt://neo4j:7687",
"--neo4j-user",
"admin",
"--neo4j-password",
"admin123",
"--embed-dim",
"768",
"--batch-size",
"128",
"--clean",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, Some("/tmp/repo".to_string()));
assert_eq!(cli.repo_name, Some("my-repo".to_string()));
assert_eq!(cli.qdrant_url, "http://qdrant:6334");
assert_eq!(cli.qdrant_collection, "custom_collection");
assert_eq!(cli.neo4j_uri, "bolt://neo4j:7687");
assert_eq!(cli.neo4j_user, "admin");
assert_eq!(cli.neo4j_password, Some("admin123".to_string()));
assert_eq!(cli.embed_dim, 768);
assert_eq!(cli.batch_size, 128);
assert!(cli.clean);
}
#[test]
fn test_parse_dependencies_single() {
assert_eq!(
parse_dependencies(Some(&"core-lib".to_string())),
vec!["core-lib"]
);
}
#[test]
fn test_parse_dependencies_multiple() {
assert_eq!(
parse_dependencies(Some(&"core-lib,shared-types,utils".to_string())),
vec!["core-lib", "shared-types", "utils"]
);
}
#[test]
fn test_parse_dependencies_with_whitespace() {
assert_eq!(
parse_dependencies(Some(&"core-lib , shared-types , utils".to_string())),
vec!["core-lib", "shared-types", "utils"]
);
}
#[test]
fn test_parse_dependencies_empty() {
assert_eq!(
parse_dependencies(Some(&"".to_string())),
Vec::<String>::new()
);
}
#[test]
fn test_parse_dependencies_with_trailing_comma() {
assert_eq!(
parse_dependencies(Some(&"core-lib,shared-types,".to_string())),
vec!["core-lib", "shared-types"]
);
}
#[test]
fn test_indexer_cli_with_watch() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--watch",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert!(cli.watch);
}
#[test]
fn test_indexer_cli_without_watch() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert!(!cli.watch);
}
#[test]
fn test_indexer_cli_repo_path_optional() {
let args = vec!["knot-indexer", "--neo4j-password", "secret"];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, None);
assert_eq!(cli.neo4j_password, Some("secret".to_string()));
}
#[test]
fn test_mcp_cli_parsing_basic() {
let args = vec![
"knot-mcp",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = McpCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, Some("/tmp/repo".to_string()));
assert_eq!(cli.neo4j_password, Some("secret".to_string()));
assert_eq!(cli.qdrant_url, "http://localhost:6334"); assert_eq!(cli.embed_dim, 384); }
#[test]
fn test_mcp_cli_repo_path_optional() {
let args = vec!["knot-mcp", "--neo4j-password", "secret"];
let cli = McpCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, None);
assert_eq!(cli.neo4j_password, Some("secret".to_string()));
}
#[test]
fn test_mcp_cli_parsing_full() {
let args = vec![
"knot-mcp",
"--repo-path",
"/tmp/repo",
"--repo-name",
"my-repo",
"--qdrant-url",
"http://qdrant:6334",
"--qdrant-collection",
"custom_collection",
"--neo4j-uri",
"bolt://neo4j:7687",
"--neo4j-user",
"admin",
"--neo4j-password",
"admin123",
"--embed-dim",
"768",
];
let cli = McpCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, Some("/tmp/repo".to_string()));
assert_eq!(cli.repo_name, Some("my-repo".to_string()));
assert_eq!(cli.qdrant_url, "http://qdrant:6334");
assert_eq!(cli.qdrant_collection, "custom_collection");
assert_eq!(cli.neo4j_uri, "bolt://neo4j:7687");
assert_eq!(cli.neo4j_user, "admin");
assert_eq!(cli.neo4j_password, Some("admin123".to_string()));
assert_eq!(cli.embed_dim, 768);
}
#[test]
fn test_mcp_cli_no_indexer_specific_options() {
let args = vec![
"knot-mcp",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--watch",
];
assert!(McpCli::try_parse_from(args).is_err());
}
#[test]
fn test_indexer_cli_accepts_all_options() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--watch",
"--clean",
"--dependencies",
"core-lib,shared-types",
"--custom-queries-path",
"/custom/queries",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.repo_path, Some("/tmp/repo".to_string()));
assert!(cli.watch);
assert!(cli.clean);
assert_eq!(cli.dependencies, Some("core-lib,shared-types".to_string()));
assert_eq!(cli.custom_queries_path, Some("/custom/queries".to_string()));
}
#[test]
fn test_resolve_repo_path_with_dot() {
let repo_path = resolve_repo_path(Some(".".to_string())).unwrap();
assert!(!repo_path.contains("."));
assert!(std::path::Path::new(&repo_path).is_absolute());
}
#[test]
fn test_resolve_repo_path_fallback_current_dir() {
let repo_path = resolve_repo_path(None).unwrap();
let expected = std::env::current_dir()
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(repo_path, expected);
assert!(std::path::Path::new(&repo_path).is_absolute());
}
#[test]
fn test_repo_name_extraction_from_canonical_path() {
let current_dir = std::env::current_dir()
.unwrap()
.to_string_lossy()
.into_owned();
let repo_name = resolve_repo_name(None, ¤t_dir);
assert!(!repo_name.is_empty());
assert!(
repo_name != "unnamed-repo" || std::env::current_dir().unwrap().file_name().is_none()
);
assert!(!repo_name.contains("/"));
assert!(!repo_name.contains("\\"));
}
#[test]
fn test_repo_name_explicit_override() {
assert_eq!(
resolve_repo_name(
Some("custom-repo-name".to_string()),
"/some/path/to/project"
),
"custom-repo-name"
);
}
#[test]
fn test_knot_cli_parsing_from_empty_args() {
let args = vec!["knot"];
let cli = McpCli::try_parse_from(args).expect("Failed to parse from empty args");
assert!(!cli.qdrant_url.is_empty());
assert!(!cli.qdrant_collection.is_empty());
assert!(!cli.neo4j_uri.is_empty());
assert!(!cli.neo4j_user.is_empty());
assert!(cli.embed_dim > 0);
}
#[test]
fn test_knot_cli_no_subcommand_interference() {
let args = vec!["knot"];
let result = McpCli::try_parse_from(args);
assert!(result.is_ok());
}
#[test]
fn test_knot_cli_env_var_precedence() {
let args = vec!["knot"];
let cli = McpCli::try_parse_from(args).expect("Failed to parse");
assert!(!cli.qdrant_url.is_empty());
assert_eq!(cli.neo4j_user, "neo4j"); }
#[test]
fn test_indexer_cli_with_custom_ca_certs() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--custom-ca-certs",
"/etc/ssl/certs/corporate-bundle.pem",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(
cli.custom_ca_certs,
Some("/etc/ssl/certs/corporate-bundle.pem".to_string())
);
}
#[test]
fn test_indexer_cli_without_custom_ca_certs() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.custom_ca_certs, None);
}
#[test]
fn test_mcp_cli_with_custom_ca_certs() {
let args = vec![
"knot-mcp",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--custom-ca-certs",
"/etc/ssl/certs/my-certs.crt",
];
let cli = McpCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(
cli.custom_ca_certs,
Some("/etc/ssl/certs/my-certs.crt".to_string())
);
}
#[test]
fn test_mcp_cli_without_custom_ca_certs() {
let args = vec![
"knot-mcp",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = McpCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.custom_ca_certs, None);
}
#[test]
fn test_config_custom_ca_certs_propagation() {
let config = Config {
repo_path: "/tmp/repo".to_string(),
repo_name: "test-repo".to_string(),
qdrant_url: "http://localhost:6334".to_string(),
qdrant_collection: "knot_entities".to_string(),
neo4j_uri: "bolt://localhost:7687".to_string(),
neo4j_user: "neo4j".to_string(),
neo4j_password: "secret".to_string(),
custom_queries_path: None,
embed_dim: 384,
batch_size: 64,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: false,
custom_ca_certs: Some("/etc/ssl/certs/corp.pem".to_string()),
output_format: OutputFormat::Table,
ingest_concurrency: 4,
rayon_threads: None,
include_config_files: false,
};
assert_eq!(
config.custom_ca_certs,
Some("/etc/ssl/certs/corp.pem".to_string())
);
}
#[test]
fn test_output_format_default_is_table() {
assert_eq!(OutputFormat::default(), OutputFormat::Table);
}
#[test]
fn test_output_format_all_variants() {
let variants = [
OutputFormat::Table,
OutputFormat::Json,
OutputFormat::Markdown,
];
assert_eq!(variants.len(), 3);
assert_ne!(OutputFormat::Table, OutputFormat::Json);
assert_ne!(OutputFormat::Table, OutputFormat::Markdown);
assert_ne!(OutputFormat::Json, OutputFormat::Markdown);
}
#[test]
fn test_output_format_clone() {
let fmt = OutputFormat::Json;
let cloned = fmt.clone();
assert_eq!(fmt, cloned);
}
#[test]
fn test_output_format_debug() {
let fmt = OutputFormat::Markdown;
let debug_str = format!("{:?}", fmt);
assert!(debug_str.contains("Markdown"));
}
#[test]
fn test_ingest_concurrency_default() {
let _guard = ENV_MUTEX.lock().unwrap();
let prev = std::env::var("KNOT_INGEST_CONCURRENCY").ok();
unsafe { std::env::remove_var("KNOT_INGEST_CONCURRENCY") };
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.ingest_concurrency, 4);
if let Some(val) = prev {
unsafe { std::env::set_var("KNOT_INGEST_CONCURRENCY", val) };
}
}
#[test]
fn test_ingest_concurrency_explicit() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--ingest-concurrency",
"8",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.ingest_concurrency, 8);
}
#[test]
fn test_ingest_concurrency_env_var() {
let _guard = ENV_MUTEX.lock().unwrap();
let prev = std::env::var("KNOT_INGEST_CONCURRENCY").ok();
unsafe { std::env::set_var("KNOT_INGEST_CONCURRENCY", "16") };
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.ingest_concurrency, 16);
if let Some(val) = prev {
unsafe { std::env::set_var("KNOT_INGEST_CONCURRENCY", val) };
} else {
unsafe { std::env::remove_var("KNOT_INGEST_CONCURRENCY") };
}
}
#[test]
fn test_ingest_concurrency_in_config() {
let config = Config {
repo_path: "/tmp/repo".to_string(),
repo_name: "test-repo".to_string(),
qdrant_url: "http://localhost:6334".to_string(),
qdrant_collection: "knot_entities".to_string(),
neo4j_uri: "bolt://localhost:7687".to_string(),
neo4j_user: "neo4j".to_string(),
neo4j_password: "secret".to_string(),
custom_queries_path: None,
embed_dim: 384,
batch_size: 64,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: false,
custom_ca_certs: None,
output_format: OutputFormat::Table,
ingest_concurrency: 8,
rayon_threads: None,
include_config_files: false,
};
assert_eq!(config.ingest_concurrency, 8);
}
#[test]
fn test_rayon_threads_default() {
let _guard = ENV_MUTEX.lock().unwrap();
let prev = std::env::var("KNOT_RAYON_THREADS").ok();
unsafe { std::env::remove_var("KNOT_RAYON_THREADS") };
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.rayon_threads, None);
if let Some(val) = prev {
unsafe { std::env::set_var("KNOT_RAYON_THREADS", val) };
}
}
#[test]
fn test_rayon_threads_explicit() {
let args = vec![
"knot-indexer",
"--repo-path",
"/tmp/repo",
"--neo4j-password",
"secret",
"--rayon-threads",
"8",
];
let cli = IndexerCli::try_parse_from(args).expect("Failed to parse CLI args");
assert_eq!(cli.rayon_threads, Some(8));
}
#[test]
fn test_rayon_threads_in_config() {
let config = Config {
repo_path: "/tmp/repo".to_string(),
repo_name: "test-repo".to_string(),
qdrant_url: "http://localhost:6334".to_string(),
qdrant_collection: "knot_entities".to_string(),
neo4j_uri: "bolt://localhost:7687".to_string(),
neo4j_user: "neo4j".to_string(),
neo4j_password: "secret".to_string(),
custom_queries_path: None,
embed_dim: 384,
batch_size: 64,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: false,
custom_ca_certs: None,
output_format: OutputFormat::Table,
ingest_concurrency: 4,
rayon_threads: Some(6),
include_config_files: false,
};
assert_eq!(config.rayon_threads, Some(6));
}
#[test]
fn test_rayon_threads_none() {
let config = Config {
repo_path: "/tmp/repo".to_string(),
repo_name: "test-repo".to_string(),
qdrant_url: "http://localhost:6334".to_string(),
qdrant_collection: "knot_entities".to_string(),
neo4j_uri: "bolt://localhost:7687".to_string(),
neo4j_user: "neo4j".to_string(),
neo4j_password: "secret".to_string(),
custom_queries_path: None,
embed_dim: 384,
batch_size: 64,
clean: false,
dependency_repos: Vec::new(),
watch: false,
dry_run: false,
custom_ca_certs: None,
output_format: OutputFormat::Table,
ingest_concurrency: 4,
rayon_threads: None,
include_config_files: false,
};
assert_eq!(config.rayon_threads, None);
}
#[test]
fn test_knot_env_path_prefers_knot_config_dir() {
unsafe { std::env::set_var("KNOT_CONFIG_DIR", "/custom/knot/config") };
let path = knot_env_path();
unsafe { std::env::remove_var("KNOT_CONFIG_DIR") };
assert_eq!(
path,
Some(std::path::PathBuf::from("/custom/knot/config/.env"))
);
}
#[test]
fn test_knot_env_path_falls_back_to_home() {
let had_config_dir = std::env::var("KNOT_CONFIG_DIR").ok();
unsafe { std::env::remove_var("KNOT_CONFIG_DIR") };
let home = std::env::var("HOME").ok();
let path = knot_env_path();
if let Some(v) = had_config_dir {
unsafe { std::env::set_var("KNOT_CONFIG_DIR", v) };
}
match home {
Some(h) => {
assert_eq!(
path,
Some(std::path::PathBuf::from(format!("{h}/.config/knot/.env")))
);
}
None => {
}
}
}
#[test]
fn test_knot_env_path_never_resolves_to_cwd() {
let temp = tempdir().unwrap();
let env_file = temp.path().join(".env");
fs::write(&env_file, "KNOT_REPO_PATH=/from/cwd\n").unwrap();
let path = knot_env_path();
assert!(
path.as_ref().is_none_or(|p| p != &env_file),
"knot_env_path must not resolve to a .env in the current (temp) directory"
);
}
#[test]
fn test_load_knot_env_reads_from_explicit_path() {
let temp = tempdir().unwrap();
let env_file = temp.path().join(".env");
fs::write(&env_file, "KNOT_REPO_PATH=/from/explicit/path\n").unwrap();
let result = dotenvy::from_path(&env_file);
assert!(result.is_ok(), "Should load .env from explicit path");
assert_eq!(
std::env::var("KNOT_REPO_PATH").unwrap(),
"/from/explicit/path"
);
unsafe { std::env::remove_var("KNOT_REPO_PATH") };
}
}