mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
project scan
This commit is contained in:
parent
3155ca76cd
commit
36a44afe2a
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston
|
||||||
|
|
||||||
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
|
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
|
||||||
|
|
||||||
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, lightweight project scans, attachable Git status context, attachable Git diff context, attachable recent commit context, and attachable repository search context.
|
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, project-local Exoshell config inspection, ignore-aware lightweight project scans, attachable Git status context, attachable Git diff context, attachable recent commit context, and attachable repository search context.
|
||||||
|
|
||||||
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).
|
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,17 @@ Or configure it:
|
||||||
root = "path/to/repo"
|
root = "path/to/repo"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Repositories can also provide project-local Exoshell config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# .exoshell.toml
|
||||||
|
[project]
|
||||||
|
honor_gitignore = true
|
||||||
|
ignore = ["generated/", "*.snapshot"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`.exoshell.local.toml` uses the same shape and overrides `.exoshell.toml`. Do not store secrets in project config; Exoshell reports likely secret keys in `/project config`.
|
||||||
|
|
||||||
## First Session
|
## First Session
|
||||||
|
|
||||||
At the prompt:
|
At the prompt:
|
||||||
|
|
@ -201,6 +212,7 @@ Inspect detected Git project state:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
exo> /project
|
exo> /project
|
||||||
|
exo> /project config
|
||||||
```
|
```
|
||||||
|
|
||||||
Preview or attach a lightweight project summary:
|
Preview or attach a lightweight project summary:
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ contain version numbers
|
||||||
imply compatibility guarantees
|
imply compatibility guarantees
|
||||||
contain offensive or discriminatory language
|
contain offensive or discriminatory language
|
||||||
be reused
|
be reused
|
||||||
|
just plainly mention the feature.
|
||||||
|
|
||||||
## Naming Guidance
|
## Naming Guidance
|
||||||
|
|
||||||
|
|
@ -94,7 +95,7 @@ Generate a new codename.
|
||||||
Verify that the codename has not been used previously.
|
Verify that the codename has not been used previously.
|
||||||
Add the codename to release notes and changelog entries.
|
Add the codename to release notes and changelog entries.
|
||||||
Preserve existing codenames in project history.
|
Preserve existing codenames in project history.
|
||||||
|
Consider an interesting, cool sounding name.
|
||||||
|
|
||||||
## Historical Codenames
|
## Historical Codenames
|
||||||
Historical codenames should be tracked in docs/versioning.md below
|
Historical codenames should be tracked in docs/versioning.md below
|
||||||
|
|
@ -109,3 +110,4 @@ Historical codenames should be tracked in docs/versioning.md below
|
||||||
* 0.8.0 commit-oracle
|
* 0.8.0 commit-oracle
|
||||||
* 0.9.0 summary-relay
|
* 0.9.0 summary-relay
|
||||||
* 0.10.0 search-relay
|
* 0.10.0 search-relay
|
||||||
|
* 0.11.0 config-compass
|
||||||
|
|
|
||||||
68
src/app.rs
68
src/app.rs
|
|
@ -13,7 +13,9 @@ use crate::context::{
|
||||||
use crate::formatting::render_assistant_output_with_policy;
|
use crate::formatting::render_assistant_output_with_policy;
|
||||||
use crate::keybindings::render_keybindings;
|
use crate::keybindings::render_keybindings;
|
||||||
use crate::project::{
|
use crate::project::{
|
||||||
ProjectError, detect_project, render_project_status, render_project_summary, summarize_project,
|
ProjectError, detect_project, load_project_config,
|
||||||
|
render_project_config as render_project_config_report, render_project_status,
|
||||||
|
render_project_summary, summarize_project,
|
||||||
};
|
};
|
||||||
use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
|
use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
|
||||||
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
|
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
|
||||||
|
|
@ -114,6 +116,10 @@ impl App {
|
||||||
return self.render_project();
|
return self.render_project();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trimmed == "/project config" {
|
||||||
|
return self.render_project_config();
|
||||||
|
}
|
||||||
|
|
||||||
if trimmed == "/project scan" || trimmed == "/project scan --preview" {
|
if trimmed == "/project scan" || trimmed == "/project scan --preview" {
|
||||||
return self.project_scan(trimmed == "/project scan --preview");
|
return self.project_scan(trimmed == "/project scan --preview");
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +376,14 @@ impl App {
|
||||||
provider_options.insert("query".into(), query.trim().to_string());
|
provider_options.insert("query".into(), query.trim().to_string());
|
||||||
|
|
||||||
let path = self.project_context_path()?;
|
let path = self.project_context_path()?;
|
||||||
|
let project_config = load_project_config(&path)?;
|
||||||
|
provider_options.insert(
|
||||||
|
"honor_gitignore".into(),
|
||||||
|
project_config.config.honor_gitignore.to_string(),
|
||||||
|
);
|
||||||
|
if !project_config.config.ignore.is_empty() {
|
||||||
|
provider_options.insert("ignore".into(), project_config.config.ignore.join("\n"));
|
||||||
|
}
|
||||||
self.add_context(
|
self.add_context(
|
||||||
"repo_search",
|
"repo_search",
|
||||||
ContextProviderRequest {
|
ContextProviderRequest {
|
||||||
|
|
@ -509,6 +523,12 @@ impl App {
|
||||||
Ok(render_project_status(project.as_ref()))
|
Ok(render_project_status(project.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_project_config(&self) -> Result<String, AppError> {
|
||||||
|
let path = self.project_context_path()?;
|
||||||
|
let report = load_project_config(&path)?;
|
||||||
|
Ok(render_project_config_report(&report))
|
||||||
|
}
|
||||||
|
|
||||||
fn render_project_status_for_panel(&self) -> String {
|
fn render_project_status_for_panel(&self) -> String {
|
||||||
let Ok(cwd) = std::env::current_dir() else {
|
let Ok(cwd) = std::env::current_dir() else {
|
||||||
return "Project\nstatus: unavailable".into();
|
return "Project\nstatus: unavailable".into();
|
||||||
|
|
@ -715,6 +735,7 @@ fn help_overview() -> &'static str {
|
||||||
"Commands:
|
"Commands:
|
||||||
/context list attached context
|
/context list attached context
|
||||||
/project show detected Git project and branch
|
/project show detected Git project and branch
|
||||||
|
/project config show project-local Exoshell config
|
||||||
/project scan [--preview] summarize project or add summary context
|
/project scan [--preview] summarize project or add summary context
|
||||||
/context stats show context and prompt budget estimates
|
/context stats show context and prompt budget estimates
|
||||||
/context show <id> inspect a context entry
|
/context show <id> inspect a context entry
|
||||||
|
|
@ -748,7 +769,7 @@ fn help_topic(topic: &str) -> &'static str {
|
||||||
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
|
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
|
||||||
}
|
}
|
||||||
"project" => {
|
"project" => {
|
||||||
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch. Use /project scan --preview to inspect a lightweight repository summary, or /project scan to add it as context."
|
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch. Use /project config to inspect .exoshell.toml, .exoshell.local.toml, ignore settings, and secret warnings. Use /project scan --preview to inspect a lightweight repository summary, or /project scan to add it as context."
|
||||||
}
|
}
|
||||||
"git" => {
|
"git" => {
|
||||||
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
|
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
|
||||||
|
|
@ -1185,10 +1206,15 @@ mod tests {
|
||||||
.expect("project")
|
.expect("project")
|
||||||
.contains("Project")
|
.contains("Project")
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
app.handle_command("/help")
|
||||||
|
.expect("help")
|
||||||
|
.contains("/project config")
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.handle_command("/help project")
|
app.handle_command("/help project")
|
||||||
.expect("help project")
|
.expect("help project")
|
||||||
.contains("Git repository")
|
.contains(".exoshell.toml")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.handle_command("/help git")
|
app.handle_command("/help git")
|
||||||
|
|
@ -1260,11 +1286,11 @@ mod tests {
|
||||||
fn project_scan_preview_and_context_add_use_summary() {
|
fn project_scan_preview_and_context_add_use_summary() {
|
||||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
let repo = tempdir.path().join("repo");
|
let repo = tempdir.path().join("repo");
|
||||||
std::fs::create_dir_all(repo.join(".git")).expect("git dir");
|
fs::create_dir_all(repo.join(".git")).expect("git dir");
|
||||||
std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
|
fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
|
||||||
std::fs::create_dir_all(repo.join("src")).expect("src dir");
|
fs::create_dir_all(repo.join("src")).expect("src dir");
|
||||||
std::fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
|
fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
|
||||||
std::fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
|
fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
|
||||||
|
|
||||||
let mut config = test_config();
|
let mut config = test_config();
|
||||||
config.project.root = Some(repo);
|
config.project.root = Some(repo);
|
||||||
|
|
@ -1287,6 +1313,32 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_config_command_renders_local_config_and_warnings() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = tempdir.path().join("repo");
|
||||||
|
fs::create_dir_all(repo.join(".git")).expect("git dir");
|
||||||
|
fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
|
||||||
|
fs::write(
|
||||||
|
repo.join(".exoshell.toml"),
|
||||||
|
"[project]\nignore = [\"generated/\"]\napi_key = \"bad\"\n",
|
||||||
|
)
|
||||||
|
.expect("config");
|
||||||
|
|
||||||
|
let mut config = test_config();
|
||||||
|
config.project.root = Some(repo);
|
||||||
|
let mut app = App::new(config, Box::new(NoopProvider));
|
||||||
|
|
||||||
|
let rendered = app
|
||||||
|
.handle_command("/project config")
|
||||||
|
.expect("project config");
|
||||||
|
|
||||||
|
assert!(rendered.contains("Project Config"));
|
||||||
|
assert!(rendered.contains(".exoshell.toml"));
|
||||||
|
assert!(rendered.contains("- generated/"));
|
||||||
|
assert!(rendered.contains("possible secret key"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn over_budget_context_fails_before_provider_request() {
|
async fn over_budget_context_fails_before_provider_request() {
|
||||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
|
||||||
135
src/context.rs
135
src/context.rs
|
|
@ -5,6 +5,8 @@ use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::project::RepositoryIgnoreRules;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ContextEntry {
|
pub struct ContextEntry {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -704,9 +706,35 @@ impl ContextProvider for RepositorySearchContextProvider {
|
||||||
.get("mode")
|
.get("mode")
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or("text");
|
.unwrap_or("text");
|
||||||
|
let honor_gitignore = request
|
||||||
|
.provider_options
|
||||||
|
.get("honor_gitignore")
|
||||||
|
.map(|value| value == "true")
|
||||||
|
.unwrap_or(true);
|
||||||
|
let ignore_patterns = request
|
||||||
|
.provider_options
|
||||||
|
.get("ignore")
|
||||||
|
.map(|value| value.lines().map(str::to_string).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let use_ripgrep = honor_gitignore && ignore_patterns.is_empty();
|
||||||
|
let ignore_rules =
|
||||||
|
RepositoryIgnoreRules::from_parts(&path, honor_gitignore, ignore_patterns).map_err(
|
||||||
|
|error| {
|
||||||
|
ContextError::InternalFailure(format!(
|
||||||
|
"failed to load repository ignore rules: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
let search = match mode {
|
let search = match mode {
|
||||||
"text" => search_text(&path, &query, self.max_results, self.max_file_bytes)?,
|
"text" => search_text(
|
||||||
"path" => search_paths(&path, &query, self.max_results)?,
|
&path,
|
||||||
|
&query,
|
||||||
|
self.max_results,
|
||||||
|
self.max_file_bytes,
|
||||||
|
&ignore_rules,
|
||||||
|
use_ripgrep,
|
||||||
|
)?,
|
||||||
|
"path" => search_paths(&path, &query, self.max_results, &ignore_rules)?,
|
||||||
other => {
|
other => {
|
||||||
return Err(ContextError::InvalidInput(format!(
|
return Err(ContextError::InvalidInput(format!(
|
||||||
"unsupported search mode '{other}', expected text or path"
|
"unsupported search mode '{other}', expected text or path"
|
||||||
|
|
@ -734,6 +762,9 @@ impl ContextProvider for RepositorySearchContextProvider {
|
||||||
provenance
|
provenance
|
||||||
.provider_details
|
.provider_details
|
||||||
.insert("truncated".into(), search.truncated.to_string());
|
.insert("truncated".into(), search.truncated.to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("honor_gitignore".into(), honor_gitignore.to_string());
|
||||||
|
|
||||||
let content = if search.results.is_empty() {
|
let content = if search.results.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -1018,50 +1049,60 @@ fn search_text(
|
||||||
query: &str,
|
query: &str,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
max_file_bytes: u64,
|
max_file_bytes: u64,
|
||||||
|
ignore_rules: &RepositoryIgnoreRules,
|
||||||
|
use_ripgrep: bool,
|
||||||
) -> Result<SearchCollection, ContextError> {
|
) -> Result<SearchCollection, ContextError> {
|
||||||
let rg_output = Command::new("rg")
|
if use_ripgrep {
|
||||||
.arg("--line-number")
|
let rg_output = Command::new("rg")
|
||||||
.arg("--column")
|
.arg("--line-number")
|
||||||
.arg("--no-heading")
|
.arg("--column")
|
||||||
.arg("--color=never")
|
.arg("--no-heading")
|
||||||
.arg("--fixed-strings")
|
.arg("--color=never")
|
||||||
.arg("--")
|
.arg("--fixed-strings")
|
||||||
.arg(query)
|
.arg("--")
|
||||||
.arg(root)
|
.arg(query)
|
||||||
.output();
|
.arg(root)
|
||||||
if let Ok(output) = rg_output {
|
.output();
|
||||||
let usable_output = output.status.success() || output.status.code() == Some(1);
|
if let Ok(output) = rg_output {
|
||||||
if usable_output {
|
let usable_output = output.status.success() || output.status.code() == Some(1);
|
||||||
let stdout = String::from_utf8(output.stdout).map_err(|error| {
|
if usable_output {
|
||||||
ContextError::UnsupportedContent(format!("rg output was not valid UTF-8: {error}"))
|
let stdout = String::from_utf8(output.stdout).map_err(|error| {
|
||||||
})?;
|
ContextError::UnsupportedContent(format!(
|
||||||
let scan_limit = max_results.saturating_add(1);
|
"rg output was not valid UTF-8: {error}"
|
||||||
let all_results: Vec<String> = stdout
|
))
|
||||||
.lines()
|
})?;
|
||||||
.take(scan_limit)
|
let scan_limit = max_results.saturating_add(1);
|
||||||
.map(|line| normalize_search_line(root, line))
|
let all_results: Vec<String> = stdout
|
||||||
.collect();
|
.lines()
|
||||||
let truncated = all_results.len() > max_results;
|
.take(scan_limit)
|
||||||
return Ok(SearchCollection {
|
.map(|line| normalize_search_line(root, line))
|
||||||
engine: SearchEngine::Ripgrep,
|
.collect();
|
||||||
results: all_results.into_iter().take(max_results).collect(),
|
let truncated = all_results.len() > max_results;
|
||||||
truncated,
|
return Ok(SearchCollection {
|
||||||
});
|
engine: SearchEngine::Ripgrep,
|
||||||
|
results: all_results.into_iter().take(max_results).collect(),
|
||||||
|
truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fallback_text_search(root, query, max_results, max_file_bytes)
|
fallback_text_search(root, query, max_results, max_file_bytes, ignore_rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search_paths(
|
fn search_paths(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
query: &str,
|
query: &str,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
|
ignore_rules: &RepositoryIgnoreRules,
|
||||||
) -> Result<SearchCollection, ContextError> {
|
) -> Result<SearchCollection, ContextError> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let needle = query.to_lowercase();
|
let needle = query.to_lowercase();
|
||||||
let scan_limit = max_results.saturating_add(1);
|
let scan_limit = max_results.saturating_add(1);
|
||||||
visit_files(root, scan_limit, &mut |path| {
|
visit_files(root, scan_limit, &mut |path| {
|
||||||
|
if ignore_rules.is_ignored(path) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
let relative = relative_path(root, path);
|
let relative = relative_path(root, path);
|
||||||
if relative.to_lowercase().contains(&needle) {
|
if relative.to_lowercase().contains(&needle) {
|
||||||
results.push(relative);
|
results.push(relative);
|
||||||
|
|
@ -1082,11 +1123,15 @@ fn fallback_text_search(
|
||||||
query: &str,
|
query: &str,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
max_file_bytes: u64,
|
max_file_bytes: u64,
|
||||||
|
ignore_rules: &RepositoryIgnoreRules,
|
||||||
) -> Result<SearchCollection, ContextError> {
|
) -> Result<SearchCollection, ContextError> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let needle = query.to_lowercase();
|
let needle = query.to_lowercase();
|
||||||
let scan_limit = max_results.saturating_add(1);
|
let scan_limit = max_results.saturating_add(1);
|
||||||
visit_files(root, scan_limit, &mut |path| {
|
visit_files(root, scan_limit, &mut |path| {
|
||||||
|
if ignore_rules.is_ignored(path) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
let Ok(metadata) = fs::metadata(path) else {
|
let Ok(metadata) = fs::metadata(path) else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
|
|
@ -2189,8 +2234,10 @@ mod tests {
|
||||||
)
|
)
|
||||||
.expect("write text");
|
.expect("write text");
|
||||||
|
|
||||||
let search =
|
let ignore_rules =
|
||||||
fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024).expect("fallback search");
|
RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
|
||||||
|
let search = fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024, &ignore_rules)
|
||||||
|
.expect("fallback search");
|
||||||
|
|
||||||
assert_eq!(search.engine, SearchEngine::Fallback);
|
assert_eq!(search.engine, SearchEngine::Fallback);
|
||||||
assert!(search.truncated);
|
assert!(search.truncated);
|
||||||
|
|
@ -2204,12 +2251,30 @@ mod tests {
|
||||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
fs::write(tempdir.path().join("large.txt"), "needle").expect("write large");
|
fs::write(tempdir.path().join("large.txt"), "needle").expect("write large");
|
||||||
|
|
||||||
let search =
|
let ignore_rules =
|
||||||
fallback_text_search(tempdir.path(), "needle", 10, 2).expect("fallback search");
|
RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
|
||||||
|
let search = fallback_text_search(tempdir.path(), "needle", 10, 2, &ignore_rules)
|
||||||
|
.expect("fallback search");
|
||||||
|
|
||||||
assert!(search.results.is_empty());
|
assert!(search.results.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_search_honors_gitignore_in_fallback() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
fs::write(tempdir.path().join(".gitignore"), "ignored.txt\n").expect("gitignore");
|
||||||
|
fs::write(tempdir.path().join("ignored.txt"), "needle\n").expect("ignored");
|
||||||
|
fs::write(tempdir.path().join("kept.txt"), "needle\n").expect("kept");
|
||||||
|
let ignore_rules =
|
||||||
|
RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
|
||||||
|
|
||||||
|
let search = fallback_text_search(tempdir.path(), "needle", 10, 256 * 1024, &ignore_rules)
|
||||||
|
.expect("fallback search");
|
||||||
|
|
||||||
|
assert_eq!(search.results.len(), 1);
|
||||||
|
assert!(search.results[0].contains("kept.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_commit_count_is_bounded() {
|
fn git_commit_count_is_bounded() {
|
||||||
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
|
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
|
||||||
|
|
|
||||||
447
src/project.rs
447
src/project.rs
|
|
@ -3,6 +3,8 @@ use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ProjectInfo {
|
pub struct ProjectInfo {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
|
@ -33,6 +35,32 @@ impl fmt::Display for ProjectHead {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProjectLocalConfig {
|
||||||
|
pub honor_gitignore: bool,
|
||||||
|
pub ignore: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProjectLocalConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
honor_gitignore: true,
|
||||||
|
ignore: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProjectConfigReport {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub shared_path: PathBuf,
|
||||||
|
pub local_path: PathBuf,
|
||||||
|
pub shared_loaded: bool,
|
||||||
|
pub local_loaded: bool,
|
||||||
|
pub config: ProjectLocalConfig,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn detect_project(
|
pub fn detect_project(
|
||||||
start: &Path,
|
start: &Path,
|
||||||
root_override: Option<&Path>,
|
root_override: Option<&Path>,
|
||||||
|
|
@ -63,6 +91,311 @@ pub fn render_project_status(project: Option<&ProjectInfo>) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_project_config(root: &Path) -> Result<ProjectConfigReport, ProjectError> {
|
||||||
|
let shared_path = root.join(".exoshell.toml");
|
||||||
|
let local_path = root.join(".exoshell.local.toml");
|
||||||
|
let mut config = ProjectLocalConfig::default();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let mut shared_loaded = false;
|
||||||
|
let mut local_loaded = false;
|
||||||
|
|
||||||
|
if shared_path.exists() {
|
||||||
|
let loaded = read_project_config_file(&shared_path)?;
|
||||||
|
config = merge_project_config(config, loaded.config);
|
||||||
|
warnings.extend(loaded.warnings);
|
||||||
|
shared_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_path.exists() {
|
||||||
|
let loaded = read_project_config_file(&local_path)?;
|
||||||
|
config = merge_project_config(config, loaded.config);
|
||||||
|
warnings.extend(loaded.warnings);
|
||||||
|
local_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProjectConfigReport {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
shared_path,
|
||||||
|
local_path,
|
||||||
|
shared_loaded,
|
||||||
|
local_loaded,
|
||||||
|
config,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_project_config(report: &ProjectConfigReport) -> String {
|
||||||
|
let mut rendered = String::new();
|
||||||
|
rendered.push_str("Project Config\n");
|
||||||
|
rendered.push_str(&format!("root: {}\n", report.root.display()));
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"shared: {} ({})\n",
|
||||||
|
report.shared_path.display(),
|
||||||
|
if report.shared_loaded {
|
||||||
|
"loaded"
|
||||||
|
} else {
|
||||||
|
"missing"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"local: {} ({})\n",
|
||||||
|
report.local_path.display(),
|
||||||
|
if report.local_loaded {
|
||||||
|
"loaded"
|
||||||
|
} else {
|
||||||
|
"missing"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"honor_gitignore: {}\n",
|
||||||
|
report.config.honor_gitignore
|
||||||
|
));
|
||||||
|
rendered.push_str("ignore:\n");
|
||||||
|
render_lines(&mut rendered, &report.config.ignore);
|
||||||
|
rendered.push_str("warnings:\n");
|
||||||
|
render_lines(&mut rendered, &report.warnings);
|
||||||
|
rendered.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RepositoryIgnoreRules {
|
||||||
|
root: PathBuf,
|
||||||
|
patterns: Vec<IgnorePattern>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryIgnoreRules {
|
||||||
|
pub fn from_project_config(
|
||||||
|
root: &Path,
|
||||||
|
config: &ProjectLocalConfig,
|
||||||
|
) -> Result<Self, ProjectError> {
|
||||||
|
Self::from_parts(root, config.honor_gitignore, config.ignore.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_parts(
|
||||||
|
root: &Path,
|
||||||
|
honor_gitignore: bool,
|
||||||
|
exoshell_patterns: Vec<String>,
|
||||||
|
) -> Result<Self, ProjectError> {
|
||||||
|
let mut patterns = Vec::new();
|
||||||
|
if honor_gitignore {
|
||||||
|
patterns.extend(read_gitignore_patterns(root)?);
|
||||||
|
}
|
||||||
|
patterns.extend(
|
||||||
|
exoshell_patterns
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(IgnorePattern::parse),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
patterns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ignored(&self, path: &Path) -> bool {
|
||||||
|
let relative = relative_display(&self.root, path);
|
||||||
|
if relative.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut ignored = is_noisy_project_path(&name);
|
||||||
|
for pattern in &self.patterns {
|
||||||
|
if pattern.matches(&relative) {
|
||||||
|
ignored = !pattern.negated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct IgnorePattern {
|
||||||
|
pattern: String,
|
||||||
|
negated: bool,
|
||||||
|
directory_only: bool,
|
||||||
|
anchored: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IgnorePattern {
|
||||||
|
fn parse(raw: String) -> Option<Self> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() || raw.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (negated, raw) = raw
|
||||||
|
.strip_prefix('!')
|
||||||
|
.map(|pattern| (true, pattern))
|
||||||
|
.unwrap_or((false, raw));
|
||||||
|
let anchored = raw.starts_with('/');
|
||||||
|
let raw = raw.trim_start_matches('/');
|
||||||
|
let directory_only = raw.ends_with('/');
|
||||||
|
let pattern = raw.trim_end_matches('/').trim();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
pattern: pattern.replace('\\', "/"),
|
||||||
|
negated,
|
||||||
|
directory_only,
|
||||||
|
anchored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, relative: &str) -> bool {
|
||||||
|
let relative = relative.replace('\\', "/");
|
||||||
|
if self.directory_only {
|
||||||
|
return relative == self.pattern || relative.starts_with(&format!("{}/", self.pattern));
|
||||||
|
}
|
||||||
|
if self.anchored || self.pattern.contains('/') {
|
||||||
|
return wildcard_match(&self.pattern, &relative);
|
||||||
|
}
|
||||||
|
relative
|
||||||
|
.split('/')
|
||||||
|
.any(|segment| wildcard_match(&self.pattern, segment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wildcard_match(pattern: &str, value: &str) -> bool {
|
||||||
|
if pattern == value {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !pattern.contains('*') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remaining = value;
|
||||||
|
let anchored_start = !pattern.starts_with('*');
|
||||||
|
let anchored_end = !pattern.ends_with('*');
|
||||||
|
let parts = pattern
|
||||||
|
.split('*')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
let Some(position) = remaining.find(part) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if index == 0 && anchored_start && position != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
remaining = &remaining[position + part.len()..];
|
||||||
|
}
|
||||||
|
|
||||||
|
!anchored_end || remaining.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||||
|
struct RawProjectConfigFile {
|
||||||
|
project: Option<RawProjectLocalConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||||
|
struct RawProjectLocalConfig {
|
||||||
|
honor_gitignore: Option<bool>,
|
||||||
|
ignore: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct LoadedProjectConfig {
|
||||||
|
config: RawProjectLocalConfig,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_project_config_file(path: &Path) -> Result<LoadedProjectConfig, ProjectError> {
|
||||||
|
let contents = fs::read_to_string(path).map_err(|error| ProjectError::Read {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
error: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let raw: RawProjectConfigFile =
|
||||||
|
toml::from_str(&contents).map_err(|error| ProjectError::ConfigParse {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
error: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let value: toml::Value =
|
||||||
|
toml::from_str(&contents).map_err(|error| ProjectError::ConfigParse {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
error: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
collect_secret_warnings(path, &value, "", &mut warnings);
|
||||||
|
|
||||||
|
Ok(LoadedProjectConfig {
|
||||||
|
config: raw.project.unwrap_or_default(),
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_project_config(
|
||||||
|
mut base: ProjectLocalConfig,
|
||||||
|
override_config: RawProjectLocalConfig,
|
||||||
|
) -> ProjectLocalConfig {
|
||||||
|
if let Some(honor_gitignore) = override_config.honor_gitignore {
|
||||||
|
base.honor_gitignore = honor_gitignore;
|
||||||
|
}
|
||||||
|
if let Some(ignore) = override_config.ignore {
|
||||||
|
base.ignore = ignore;
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_gitignore_patterns(root: &Path) -> Result<Vec<IgnorePattern>, ProjectError> {
|
||||||
|
let path = root.join(".gitignore");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(&path).map_err(|error| ProjectError::Read {
|
||||||
|
path,
|
||||||
|
error: error.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(contents
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| IgnorePattern::parse(line.to_string()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_secret_warnings(
|
||||||
|
path: &Path,
|
||||||
|
value: &toml::Value,
|
||||||
|
prefix: &str,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
let Some(table) = value.as_table() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (key, value) in table {
|
||||||
|
let full_key = if prefix.is_empty() {
|
||||||
|
key.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{key}")
|
||||||
|
};
|
||||||
|
if looks_secret_key(key) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"possible secret key '{full_key}' in {}; project config should not store secrets",
|
||||||
|
path.file_name()
|
||||||
|
.map(|name| name.to_string_lossy())
|
||||||
|
.unwrap_or_else(|| path.as_os_str().to_string_lossy())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
collect_secret_warnings(path, value, &full_key, warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_secret_key(key: &str) -> bool {
|
||||||
|
let key = key.to_ascii_lowercase();
|
||||||
|
key.contains("secret")
|
||||||
|
|| key.contains("token")
|
||||||
|
|| key.contains("password")
|
||||||
|
|| key.contains("api_key")
|
||||||
|
|| key.contains("apikey")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ProjectSummary {
|
pub struct ProjectSummary {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
|
@ -86,7 +419,9 @@ pub fn summarize_project(
|
||||||
root_override: Option<&Path>,
|
root_override: Option<&Path>,
|
||||||
) -> Result<ProjectSummary, ProjectError> {
|
) -> Result<ProjectSummary, ProjectError> {
|
||||||
let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?;
|
let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?;
|
||||||
Ok(summarize_project_root(&project))
|
let config = load_project_config(&project.root)?;
|
||||||
|
let ignore_rules = RepositoryIgnoreRules::from_project_config(&project.root, &config.config)?;
|
||||||
|
Ok(summarize_project_root(&project, &ignore_rules))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_project_summary(summary: &ProjectSummary) -> String {
|
pub fn render_project_summary(summary: &ProjectSummary) -> String {
|
||||||
|
|
@ -120,9 +455,12 @@ pub fn render_project_summary(summary: &ProjectSummary) -> String {
|
||||||
rendered.trim_end().to_string()
|
rendered.trim_end().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
|
fn summarize_project_root(
|
||||||
|
project: &ProjectInfo,
|
||||||
|
ignore_rules: &RepositoryIgnoreRules,
|
||||||
|
) -> ProjectSummary {
|
||||||
const MAX_FILES: usize = 1_000;
|
const MAX_FILES: usize = 1_000;
|
||||||
let major_directories = major_directories(&project.root);
|
let major_directories = major_directories(&project.root, ignore_rules);
|
||||||
let mut languages = BTreeMap::<String, usize>::new();
|
let mut languages = BTreeMap::<String, usize>::new();
|
||||||
let mut entry_points = Vec::new();
|
let mut entry_points = Vec::new();
|
||||||
let mut build_files = Vec::new();
|
let mut build_files = Vec::new();
|
||||||
|
|
@ -139,8 +477,7 @@ fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry_path = entry.path();
|
let entry_path = entry.path();
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
if ignore_rules.is_ignored(&entry_path) {
|
||||||
if is_noisy_project_path(&name) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(file_type) = entry.file_type() else {
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
|
@ -204,7 +541,7 @@ fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn major_directories(root: &Path) -> Vec<String> {
|
fn major_directories(root: &Path, ignore_rules: &RepositoryIgnoreRules) -> Vec<String> {
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
@ -212,7 +549,7 @@ fn major_directories(root: &Path) -> Vec<String> {
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.filter_map(|entry| {
|
.filter_map(|entry| {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if is_noisy_project_path(&name) || !entry.file_type().ok()?.is_dir() {
|
if ignore_rules.is_ignored(&entry.path()) || !entry.file_type().ok()?.is_dir() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(format!("{name}/"))
|
Some(format!("{name}/"))
|
||||||
|
|
@ -388,6 +725,8 @@ fn short_commit(value: &str) -> String {
|
||||||
pub enum ProjectError {
|
pub enum ProjectError {
|
||||||
#[error("failed to read project metadata at {path}: {error}")]
|
#[error("failed to read project metadata at {path}: {error}")]
|
||||||
Read { path: PathBuf, error: String },
|
Read { path: PathBuf, error: String },
|
||||||
|
#[error("failed to parse project config at {path}: {error}")]
|
||||||
|
ConfigParse { path: PathBuf, error: String },
|
||||||
#[error("invalid git file at {0}")]
|
#[error("invalid git file at {0}")]
|
||||||
InvalidGitFile(PathBuf),
|
InvalidGitFile(PathBuf),
|
||||||
#[error("project not detected")]
|
#[error("project not detected")]
|
||||||
|
|
@ -494,6 +833,100 @@ mod tests {
|
||||||
assert!(rendered.contains("Rust"));
|
assert!(rendered.contains("Rust"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_config_loads_shared_and_local_with_local_precedence() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = tempdir.path().join("repo");
|
||||||
|
write_head(&repo, "main");
|
||||||
|
fs::write(
|
||||||
|
repo.join(".exoshell.toml"),
|
||||||
|
"[project]\nhonor_gitignore = true\nignore = [\"generated\"]\n",
|
||||||
|
)
|
||||||
|
.expect("shared config");
|
||||||
|
fs::write(
|
||||||
|
repo.join(".exoshell.local.toml"),
|
||||||
|
"[project]\nhonor_gitignore = false\nignore = [\"local-only\"]\napi_token = \"bad\"\n",
|
||||||
|
)
|
||||||
|
.expect("local config");
|
||||||
|
|
||||||
|
let report = load_project_config(&repo).expect("project config");
|
||||||
|
let rendered = render_project_config(&report);
|
||||||
|
|
||||||
|
assert!(report.shared_loaded);
|
||||||
|
assert!(report.local_loaded);
|
||||||
|
assert!(!report.config.honor_gitignore);
|
||||||
|
assert_eq!(report.config.ignore, vec!["local-only".to_string()]);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|warning| warning.contains("api_token"))
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("honor_gitignore: false"));
|
||||||
|
assert!(rendered.contains("- local-only"));
|
||||||
|
assert!(rendered.contains("possible secret key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_summary_honors_gitignore_and_exoshell_ignore_rules() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = tempdir.path().join("repo");
|
||||||
|
write_head(&repo, "main");
|
||||||
|
fs::create_dir_all(repo.join("src")).expect("src dir");
|
||||||
|
fs::create_dir_all(repo.join("generated")).expect("generated dir");
|
||||||
|
fs::write(repo.join(".gitignore"), "ignored.py\n").expect("gitignore");
|
||||||
|
fs::write(
|
||||||
|
repo.join(".exoshell.toml"),
|
||||||
|
"[project]\nignore = [\"generated/\"]\n",
|
||||||
|
)
|
||||||
|
.expect("project config");
|
||||||
|
fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
|
||||||
|
fs::write(repo.join("ignored.py"), "print('ignored')\n").expect("ignored");
|
||||||
|
fs::write(repo.join("generated").join("ignored.ts"), "ignored\n").expect("generated");
|
||||||
|
|
||||||
|
let summary = summarize_project(&repo, None).expect("summary");
|
||||||
|
|
||||||
|
assert!(summary.entry_points.contains(&"src/main.rs".to_string()));
|
||||||
|
assert!(
|
||||||
|
summary
|
||||||
|
.languages
|
||||||
|
.iter()
|
||||||
|
.any(|language| language.language == "Rust")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!summary
|
||||||
|
.languages
|
||||||
|
.iter()
|
||||||
|
.any(|language| language.language == "Python")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!summary
|
||||||
|
.languages
|
||||||
|
.iter()
|
||||||
|
.any(|language| language.language == "TypeScript")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!summary
|
||||||
|
.major_directories
|
||||||
|
.contains(&"generated/".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ignore_rules_support_negation_and_wildcards() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let rules = RepositoryIgnoreRules::from_parts(
|
||||||
|
tempdir.path(),
|
||||||
|
false,
|
||||||
|
vec!["*.log".into(), "!keep.log".into(), "cache/".into()],
|
||||||
|
)
|
||||||
|
.expect("rules");
|
||||||
|
|
||||||
|
assert!(rules.is_ignored(&tempdir.path().join("debug.log")));
|
||||||
|
assert!(!rules.is_ignored(&tempdir.path().join("keep.log")));
|
||||||
|
assert!(rules.is_ignored(&tempdir.path().join("cache").join("data.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
fn write_head(root: &Path, branch: &str) {
|
fn write_head(root: &Path, branch: &str) {
|
||||||
let git_dir = root.join(".git");
|
let git_dir = root.join(".git");
|
||||||
fs::create_dir_all(&git_dir).expect("git dir");
|
fs::create_dir_all(&git_dir).expect("git dir");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user