mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Repo summary generation
This commit is contained in:
parent
cae34e0c86
commit
ce9937f1dd
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.8.0"
|
version = "0.9.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, initial Git-native project detection, attachable Git status context, attachable Git diff context, and attachable recent commit 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, lightweight project scans, attachable Git status context, attachable Git diff context, and attachable recent commit 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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,13 @@ Inspect detected Git project state:
|
||||||
exo> /project
|
exo> /project
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Preview or attach a lightweight project summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exo> /project scan --preview
|
||||||
|
exo> /project scan
|
||||||
|
```
|
||||||
|
|
||||||
Attach current Git branch and working tree state as explicit context:
|
Attach current Git branch and working tree state as explicit context:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -107,3 +107,4 @@ Historical codenames should be tracked in docs/versioning.md below
|
||||||
* 0.6.0 status-satellite
|
* 0.6.0 status-satellite
|
||||||
* 0.7.0 diff-lantern
|
* 0.7.0 diff-lantern
|
||||||
* 0.8.0 commit-oracle
|
* 0.8.0 commit-oracle
|
||||||
|
* 0.9.0 summary-relay
|
||||||
|
|
|
||||||
93
src/app.rs
93
src/app.rs
|
|
@ -5,13 +5,16 @@ use std::time::Duration;
|
||||||
use crate::commands::{CommandSuggestion, parse_command_suggestions_with_policy};
|
use crate::commands::{CommandSuggestion, parse_command_suggestions_with_policy};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::context::{
|
use crate::context::{
|
||||||
ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest,
|
ContextEntry, ContextError, ContextKind, ContextOrigin, ContextPriority, ContextProvenance,
|
||||||
SessionContextStore, budget_warning, prune_context, register_default_context_providers,
|
ContextProviderRegistry, ContextProviderRequest, SessionContextStore, budget_warning,
|
||||||
render_context_details, render_context_list, render_context_stats,
|
prune_context, register_default_context_providers, render_context_details, render_context_list,
|
||||||
|
render_context_stats,
|
||||||
};
|
};
|
||||||
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::{ProjectError, detect_project, render_project_status};
|
use crate::project::{
|
||||||
|
ProjectError, detect_project, 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};
|
||||||
use crate::repl::ReplError;
|
use crate::repl::ReplError;
|
||||||
|
|
@ -111,6 +114,10 @@ impl App {
|
||||||
return self.render_project();
|
return self.render_project();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trimmed == "/project scan" || trimmed == "/project scan --preview" {
|
||||||
|
return self.project_scan(trimmed == "/project scan --preview");
|
||||||
|
}
|
||||||
|
|
||||||
if trimmed == "/help" {
|
if trimmed == "/help" {
|
||||||
return Ok(help_overview().into());
|
return Ok(help_overview().into());
|
||||||
}
|
}
|
||||||
|
|
@ -342,6 +349,39 @@ impl App {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn project_scan(&mut self, preview: bool) -> Result<String, AppError> {
|
||||||
|
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
||||||
|
path: PathBuf::from("."),
|
||||||
|
error: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let summary = summarize_project(&cwd, self.config.project.root.as_deref())?;
|
||||||
|
let rendered = render_project_summary(&summary);
|
||||||
|
if preview {
|
||||||
|
return Ok(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut provenance = ContextProvenance::new(ContextOrigin::Generated);
|
||||||
|
provenance.source_path = Some(summary.root.clone());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("branch".into(), summary.branch.clone());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("files_seen".into(), summary.files_seen.to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("truncated".into(), summary.truncated.to_string());
|
||||||
|
let entry = ContextEntry::new(
|
||||||
|
"",
|
||||||
|
ContextKind::ProjectSummary,
|
||||||
|
"project summary",
|
||||||
|
provenance,
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
.with_priority(ContextPriority::High);
|
||||||
|
Ok(format!("added {}", self.add_context_entry(entry)?))
|
||||||
|
}
|
||||||
|
|
||||||
fn project_context_path(&self) -> Result<PathBuf, AppError> {
|
fn project_context_path(&self) -> Result<PathBuf, AppError> {
|
||||||
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
||||||
path: PathBuf::from("."),
|
path: PathBuf::from("."),
|
||||||
|
|
@ -551,6 +591,17 @@ impl App {
|
||||||
Ok(format!("added {} ({})", entry.id, entry.title))
|
Ok(format!("added {} ({})", entry.id, entry.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_context_entry(&mut self, entry: ContextEntry) -> Result<String, AppError> {
|
||||||
|
let id = self.context_store.add(entry);
|
||||||
|
let entry = self
|
||||||
|
.context_store
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| ContextError::NotFound(id.clone()))?;
|
||||||
|
self.transcript
|
||||||
|
.record_context_event("add", entry, "added to session context");
|
||||||
|
Ok(format!("{} ({})", entry.id, entry.title))
|
||||||
|
}
|
||||||
|
|
||||||
fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<String, AppError> {
|
fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<String, AppError> {
|
||||||
self.context_store.set_enabled(id, enabled)?;
|
self.context_store.set_enabled(id, enabled)?;
|
||||||
let entry = self
|
let entry = self
|
||||||
|
|
@ -633,6 +684,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 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
|
||||||
/context enable|disable <id> control model inclusion
|
/context enable|disable <id> control model inclusion
|
||||||
|
|
@ -663,7 +715,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."
|
"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."
|
||||||
}
|
}
|
||||||
"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."
|
||||||
|
|
@ -1133,6 +1185,37 @@ mod tests {
|
||||||
assert!(parse_git_commit_args("one two").is_err());
|
assert!(parse_git_commit_args("one two").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_scan_preview_and_context_add_use_summary() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = tempdir.path().join("repo");
|
||||||
|
std::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");
|
||||||
|
std::fs::create_dir_all(repo.join("src")).expect("src dir");
|
||||||
|
std::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");
|
||||||
|
|
||||||
|
let mut config = test_config();
|
||||||
|
config.project.root = Some(repo);
|
||||||
|
let mut app = App::new(config, Box::new(NoopProvider));
|
||||||
|
|
||||||
|
let preview = app
|
||||||
|
.handle_command("/project scan --preview")
|
||||||
|
.expect("preview");
|
||||||
|
assert!(preview.contains("Project Summary"));
|
||||||
|
assert!(preview.contains("src/main.rs"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.handle_command("/project scan").expect("scan"),
|
||||||
|
"added ctx-001 (project summary)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.handle_command("/context")
|
||||||
|
.expect("context")
|
||||||
|
.contains("project_summary")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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()));
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ pub enum ContextKind {
|
||||||
Note,
|
Note,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
NotebookEntry,
|
NotebookEntry,
|
||||||
|
ProjectSummary,
|
||||||
Manual,
|
Manual,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +89,7 @@ impl fmt::Display for ContextKind {
|
||||||
Self::Note => formatter.write_str("note"),
|
Self::Note => formatter.write_str("note"),
|
||||||
Self::SearchResult => formatter.write_str("search_result"),
|
Self::SearchResult => formatter.write_str("search_result"),
|
||||||
Self::NotebookEntry => formatter.write_str("notebook_entry"),
|
Self::NotebookEntry => formatter.write_str("notebook_entry"),
|
||||||
|
Self::ProjectSummary => formatter.write_str("project_summary"),
|
||||||
Self::Manual => formatter.write_str("manual"),
|
Self::Manual => formatter.write_str("manual"),
|
||||||
Self::Unknown(value) => write!(formatter, "unknown:{value}"),
|
Self::Unknown(value) => write!(formatter, "unknown:{value}"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
263
src/project.rs
263
src/project.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -62,6 +63,242 @@ pub fn render_project_status(project: Option<&ProjectInfo>) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProjectSummary {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub branch: String,
|
||||||
|
pub major_directories: Vec<String>,
|
||||||
|
pub languages: Vec<LanguageSummary>,
|
||||||
|
pub entry_points: Vec<String>,
|
||||||
|
pub build_files: Vec<String>,
|
||||||
|
pub files_seen: usize,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LanguageSummary {
|
||||||
|
pub language: String,
|
||||||
|
pub files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize_project(
|
||||||
|
start: &Path,
|
||||||
|
root_override: Option<&Path>,
|
||||||
|
) -> Result<ProjectSummary, ProjectError> {
|
||||||
|
let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?;
|
||||||
|
Ok(summarize_project_root(&project))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_project_summary(summary: &ProjectSummary) -> String {
|
||||||
|
let mut rendered = String::new();
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"Project Summary\nroot: {}\n",
|
||||||
|
summary.root.display()
|
||||||
|
));
|
||||||
|
rendered.push_str(&format!("branch: {}\n", summary.branch));
|
||||||
|
rendered.push_str(&format!("files_seen: {}\n", summary.files_seen));
|
||||||
|
rendered.push_str(&format!("truncated: {}\n\n", summary.truncated));
|
||||||
|
|
||||||
|
rendered.push_str("major_directories:\n");
|
||||||
|
render_lines(&mut rendered, &summary.major_directories);
|
||||||
|
rendered.push_str("\nlanguages:\n");
|
||||||
|
if summary.languages.is_empty() {
|
||||||
|
rendered.push_str("- none\n");
|
||||||
|
} else {
|
||||||
|
for language in &summary.languages {
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"- {}: {} files\n",
|
||||||
|
language.language, language.files
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rendered.push_str("\nentry_points:\n");
|
||||||
|
render_lines(&mut rendered, &summary.entry_points);
|
||||||
|
rendered.push_str("\nbuild_files:\n");
|
||||||
|
render_lines(&mut rendered, &summary.build_files);
|
||||||
|
|
||||||
|
rendered.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
|
||||||
|
const MAX_FILES: usize = 1_000;
|
||||||
|
let major_directories = major_directories(&project.root);
|
||||||
|
let mut languages = BTreeMap::<String, usize>::new();
|
||||||
|
let mut entry_points = Vec::new();
|
||||||
|
let mut build_files = Vec::new();
|
||||||
|
let mut files_seen = 0;
|
||||||
|
let mut truncated = false;
|
||||||
|
let mut stack = vec![project.root.clone()];
|
||||||
|
|
||||||
|
while let Some(path) = stack.pop() {
|
||||||
|
let Ok(entries) = fs::read_dir(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
|
||||||
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if is_noisy_project_path(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if file_type.is_dir() {
|
||||||
|
stack.push(entry_path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
files_seen += 1;
|
||||||
|
if files_seen > MAX_FILES {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative = relative_display(&project.root, &entry_path);
|
||||||
|
if let Some(language) = language_for_path(&entry_path) {
|
||||||
|
*languages.entry(language.into()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if is_likely_entry_point(&relative) {
|
||||||
|
entry_points.push(relative.clone());
|
||||||
|
}
|
||||||
|
if is_build_file(&relative) {
|
||||||
|
build_files.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut languages = languages
|
||||||
|
.into_iter()
|
||||||
|
.map(|(language, files)| LanguageSummary { language, files })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
languages.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.files
|
||||||
|
.cmp(&left.files)
|
||||||
|
.then(left.language.cmp(&right.language))
|
||||||
|
});
|
||||||
|
entry_points.sort();
|
||||||
|
entry_points.dedup();
|
||||||
|
build_files.sort();
|
||||||
|
build_files.dedup();
|
||||||
|
|
||||||
|
ProjectSummary {
|
||||||
|
root: project.root.clone(),
|
||||||
|
branch: project.branch_label(),
|
||||||
|
major_directories,
|
||||||
|
languages,
|
||||||
|
entry_points,
|
||||||
|
build_files,
|
||||||
|
files_seen: files_seen.min(MAX_FILES),
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn major_directories(root: &Path) -> Vec<String> {
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut directories = entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if is_noisy_project_path(&name) || !entry.file_type().ok()?.is_dir() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("{name}/"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
directories.sort();
|
||||||
|
directories
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_lines(rendered: &mut String, lines: &[String]) {
|
||||||
|
if lines.is_empty() {
|
||||||
|
rendered.push_str("- none\n");
|
||||||
|
} else {
|
||||||
|
for line in lines {
|
||||||
|
rendered.push_str(&format!("- {line}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_display(root: &Path, path: &Path) -> String {
|
||||||
|
path.strip_prefix(root)
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_noisy_project_path(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
".git"
|
||||||
|
| "target"
|
||||||
|
| "node_modules"
|
||||||
|
| ".venv"
|
||||||
|
| "venv"
|
||||||
|
| "dist"
|
||||||
|
| "build"
|
||||||
|
| ".next"
|
||||||
|
| ".cache"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn language_for_path(path: &Path) -> Option<&'static str> {
|
||||||
|
match path.extension()?.to_string_lossy().as_ref() {
|
||||||
|
"rs" => Some("Rust"),
|
||||||
|
"toml" => Some("TOML"),
|
||||||
|
"md" => Some("Markdown"),
|
||||||
|
"js" | "jsx" => Some("JavaScript"),
|
||||||
|
"ts" | "tsx" => Some("TypeScript"),
|
||||||
|
"py" => Some("Python"),
|
||||||
|
"ps1" => Some("PowerShell"),
|
||||||
|
"sh" | "bash" | "zsh" => Some("Shell"),
|
||||||
|
"json" => Some("JSON"),
|
||||||
|
"yaml" | "yml" => Some("YAML"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_likely_entry_point(relative: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
relative,
|
||||||
|
"src/main.rs"
|
||||||
|
| "src/lib.rs"
|
||||||
|
| "main.py"
|
||||||
|
| "app.py"
|
||||||
|
| "index.js"
|
||||||
|
| "index.ts"
|
||||||
|
| "src/index.js"
|
||||||
|
| "src/index.ts"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_build_file(relative: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
relative,
|
||||||
|
"Cargo.toml"
|
||||||
|
| "package.json"
|
||||||
|
| "pyproject.toml"
|
||||||
|
| "Makefile"
|
||||||
|
| "justfile"
|
||||||
|
| "go.mod"
|
||||||
|
| "pom.xml"
|
||||||
|
| "build.gradle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_start(start: &Path, root_override: Option<&Path>) -> PathBuf {
|
fn resolve_start(start: &Path, root_override: Option<&Path>) -> PathBuf {
|
||||||
match root_override {
|
match root_override {
|
||||||
Some(root) if root.is_absolute() => root.to_path_buf(),
|
Some(root) if root.is_absolute() => root.to_path_buf(),
|
||||||
|
|
@ -153,6 +390,8 @@ pub enum ProjectError {
|
||||||
Read { path: PathBuf, error: String },
|
Read { path: PathBuf, error: String },
|
||||||
#[error("invalid git file at {0}")]
|
#[error("invalid git file at {0}")]
|
||||||
InvalidGitFile(PathBuf),
|
InvalidGitFile(PathBuf),
|
||||||
|
#[error("project not detected")]
|
||||||
|
NotDetected,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -231,6 +470,30 @@ mod tests {
|
||||||
assert_eq!(project, None);
|
assert_eq!(project, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarizes_project_without_full_indexing() {
|
||||||
|
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("target")).expect("target dir");
|
||||||
|
fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
|
||||||
|
fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
|
||||||
|
fs::write(repo.join("README.md"), "# demo\n").expect("readme");
|
||||||
|
fs::write(repo.join("target").join("ignored.rs"), "ignored").expect("ignored");
|
||||||
|
|
||||||
|
let summary = summarize_project(&repo, None).expect("summary");
|
||||||
|
let rendered = render_project_summary(&summary);
|
||||||
|
|
||||||
|
assert_eq!(summary.branch, "main");
|
||||||
|
assert!(summary.major_directories.contains(&"src/".to_string()));
|
||||||
|
assert!(!summary.major_directories.contains(&"target/".to_string()));
|
||||||
|
assert!(summary.entry_points.contains(&"src/main.rs".to_string()));
|
||||||
|
assert!(summary.build_files.contains(&"Cargo.toml".to_string()));
|
||||||
|
assert!(rendered.contains("languages:"));
|
||||||
|
assert!(rendered.contains("Rust"));
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ impl Repl {
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.starts_with("/context")
|
if input.starts_with("/context")
|
||||||
|| input == "/project"
|
|| input.starts_with("/project")
|
||||||
|| input.starts_with("/stance")
|
|| input.starts_with("/stance")
|
||||||
|| input.starts_with("/copy ")
|
|| input.starts_with("/copy ")
|
||||||
|| input.starts_with("/explain ")
|
|| input.starts_with("/explain ")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user