
**Code2Prompt** is a powerful context engineering tool designed to ingest codebases and format them for Large Language Models. Whether you are manually copying context for ChatGPT, building AI agents via Python, or running a MCP server, Code2Prompt streamlines the context preparation process.
## ⚡ Quick Install
### Cargo
```bash
cargo install code2prompt
```
To enable optional Wayland support (e.g., for clipboard integration on Wayland-based systems), use the `wayland` feature flag:
```bash
cargo install --features wayland code2prompt
```
### Homebrew
```bash
brew install code2prompt
```
### SDK with pip 🐍
```bash
pip install code2prompt-rs
```
## 🚀 Quick Start
Once installed, generating a prompt from your codebase is as simple as pointing the tool to your directory.
**Basic Usage**: Generate a prompt from the current directory and copy it to the clipboard.
```sh
code2prompt .
```
**Save to file**:
```sh
code2prompt path/to/project --output-file prompt.txt
```
## 🌐 Ecosystem
Code2Prompt is more than just a CLI tool. It is a complete ecosystem for codebase context.
| 🧱 Core Library | 💻 CLI Tool | 🐍 Python SDK | 🤖 MCP Server |
| :---: | :---: | :---: | :---: |
| The internal, high-speed library responsible for secure file traversal, respecting `.gitignore` rules, and structuring Git metadata. | Designed for humans, featuring both a minimal CLI and an interactive TUI. Generate formatted prompts, track token usage, and outputs the result to your clipboard or stdout. | Provides fast Python bindings to the Rust Core. Ideal for AI Agents, automation scripts, or deep integration into RAG pipelines. Available on PyPI. | Run Code2Prompt as a local service, enabling agentic applications to read your local codebase efficiently without bloating your context window. |
## 📚 Documentation
Check our online [documentation](https://code2prompt.dev/docs/welcome/) for detailed instructions
## ✨ Features
Code2Prompt transforms your entire codebase into a well-structured prompt for large language models. Key features include:
- **Terminal User Interface (TUI)**: Interactive terminal interface for configuring and generating prompts
- **Smart Filtering**: Include/exclude files using glob patterns and respect `.gitignore` rules
- **Flexible Templating**: Customize prompts with Handlebars templates for different use cases
- **Automatic Code Processing**: Convert codebases of any size into readable, formatted prompts
- **Token Tracking**: Track token usage to stay within LLM context limits
- **Smart File Reading**: Simplify reading various file formats for LLMs (CSV, Notebooks, JSONL, etc.)
- **Git Integration**: Include diffs, logs, and branch comparisons in your prompts
- **Blazing Fast**: Built in Rust for high performance and low resource usage
Stop manually copying files and formatting code for LLMs. Code2Prompt handles the tedious work so you can focus on getting insights and solutions from AI models.
## Alternative Installation
Refer to the [documentation](https://code2prompt.dev/docs/how_to/install/) for detailed installation instructions.
### Binary releases
Download the latest binary for your OS from [Releases](https://github.com/mufeedvh/code2prompt/releases).
### Source build
Requires:
- [Git](https://git-scm.org/downloads), [Rust](https://rust-lang.org/tools/install) and `Cargo`.
```sh
git clone https://github.com/mufeedvh/code2prompt.git
cd code2prompt/
cargo install --path crates/code2prompt
```
## ⭐ Star Gazing
[](https://star-history.com/#mufeedvh/code2prompt&Date)
## 📜 License
Licensed under the MIT License, see LICENSE for more information.
## Liked the project?
If you liked the project and found it useful, please give it a :star: !
## 👥 Contribution
Ways to contribute:
- Suggest a feature
- Report a bug
- Fix something and open a pull request
- Help me document the code
- Spread the word
================================================
FILE: README_ES.md
================================================
# code2prompt
[](https://crates.io/crates/code2prompt)
[](https://github.com/mufeedvh/code2prompt/blob/master/LICENSE)
`code2prompt` es una herramienta de línea de comandos (CLI) que convierte tu base de código en un único prompt para LLM, incluyendo un árbol de archivos fuente, plantillas de prompts y conteo de tokens.
## Tabla de Contenidos
- [Características](#features)
- [Instalación](#installation)
- [Uso](#usage)
- [Plantillas](#templates)
- [Variables Definidas por el Usuario](#user-defined-variables)
- [Tokenizadores](#tokenizers)
- [Contribución](#contribution)
- [Licencia](#license)
- [Apoya al Autor](#support-the-author)
## Características
Puedes ejecutar esta herramienta en un directorio completo, y generará un prompt bien formateado en Markdown que detalla la estructura del árbol de archivos fuente y todo el código. Luego puedes cargar este documento en modelos como GPT o Claude con ventanas de contexto amplias y pedirles que:
- Generen prompts para LLM rápidamente a partir de bases de código de cualquier tamaño.
- Personalicen la generación de prompts usando plantillas de Handlebars (ver la [plantilla predeterminada](src/default_template.hbs))
- Respete los archivos `.gitignore`.
- Filtren y excluyan archivos utilizando patrones glob.
- Muestren el conteo de tokens del prompt generado (Ver [Tokenizadores](#tokenizers) para más detalles).
- Incluyan opcionalmente salidas de `git diff` (archivos en estado staged) en el prompt generado.
- Copien automáticamente el prompt generado al portapapeles.
- Guarden el prompt generado en un archivo de salida.
- Excluyan archivos y carpetas por nombre o ruta.
- Añadan números de línea a los bloques de código fuente.
Puedes personalizar las plantillas de prompts para lograr cualquier caso de uso deseado. Básicamente, recorre una base de código y crea un prompt con todos los archivos fuente combinados. En resumen, automatiza la tarea de copiar y formatear múltiples archivos fuente en un único prompt y te informa cuántos tokens consume.
## Instalación
### Lanzamiento de binarios
Descarga el binario más reciente para tu sistema operativo desde [Releases](https://github.com/mufeedvh/code2prompt/releases).
### Construcción desde código fuente
Requisitos:
- [Git](https://git-scm.org/downloads), [Rust](https://rust-lang.org/tools/install) y Cargo.
```sh
git clone https://github.com/mufeedvh/code2prompt.git
cd code2prompt/
cargo build --release
```
## cargo
```bash
# Cargo
$ cargo install code2prompt
# Homebrew
$ brew install code2prompt
```
Para versiones no publicadas:
```sh
cargo install --git https://github.com/mufeedvh/code2prompt
```
### AUR
`code2prompt` está disponible en [`AUR`](https://aur.archlinux.org/packages?O=0&K=code2prompt). Instálalo usando cualquier gestor AUR.
```sh
paru/yay -S code2prompt
```
### Nix
Si utilizas Nix, puedes instalarlo con `nix-env` o `profile`:
```sh
# Sin flakes:
nix-env -iA nixpkgs.code2prompt
# Con flakes:
nix profile install nixpkgs#code2prompt
```
## Uso
Genera un prompt desde un directorio de código:
```sh
code2prompt path/to/codebase
```
Usa un archivo de plantilla Handlebars personalizado:
```sh
code2prompt path/to/codebase -t path/to/template.hbs
```
Filtrar archivos usando patrones glob:
```sh
code2prompt path/to/codebase --include="*.rs,*.toml"
```
Excluir archivos usando patrones glob:
```sh
code2prompt path/to/codebase --exclude="*.txt,*.md"
```
Excluir archivos/carpetas del árbol de origen basándose en patrones de exclusión:
```sh
code2prompt path/to/codebase --exclude="*.npy,*.wav" --exclude-from-tree
```
Mostrar el conteo de tokens del prompt generado:
```sh
code2prompt path/to/codebase --tokens
```
Especificar un tokenizador para el conteo de tokens:
```sh
code2prompt path/to/codebase --tokens --encoding=p50k
```
Tokenizadores soportados: `cl100k`, `p50k`, `p50k_edit`, `r50k_bas`.
> [!NOTE]
> Ver [Tokenizadores](#tokenizers) para más detalles.
Guardar el prompt generado en un archivo de salida:
```sh
code2prompt path/to/codebase --output=output.txt
```
Imprimir salida como JSON:
```sh
code2prompt path/to/codebase --json
```
La salida JSON tendrá la siguiente estructura:
```json
{
"prompt": "",
"directory_name": "codebase",
"token_count": 1234,
"model_info": "Modelos de ChatGPT, text-embedding-ada-002",
"files": []
}
```
Generar un mensaje de commit de Git (para archivos en estado staged):
```sh
code2prompt path/to/codebase --diff -t templates/write-git-commit.hbs
```
Generar una Pull Request comparando ramas (para archivos en estado staged):
```sh
code2prompt path/to/codebase --git-diff-branch 'main, development' --git-log-branch 'main, development' -t templates/write-github-pull-request.hbs
```
Añadir números de línea a los bloques de código fuente:
```sh
code2prompt path/to/codebase --line-number
```
Desactivar el envoltorio de código dentro de bloques de código markdown:
```sh
code2prompt path/to/codebase --no-codeblock
```
- Reescribir el código a otro idioma.
- Encontrar errores/vulnerabilidades de seguridad.
- Documentar el código.
- Implementar nuevas características.
> Inicialmente escribí esto para uso personal para utilizar la ventana de contexto de 200K de Claude 3.0 y ha resultado ser bastante útil, ¡así que decidí hacerlo de código abierto!
## Plantillas
`code2prompt` viene con un conjunto de plantillas integradas para casos de uso comunes. Puedes encontrarlas en el directorio [`templates`](templates).
### [`document-the-code.hbs`](templates/document-the-code.hbs)
Usa esta plantilla para generar prompts para documentar el código. Añadirá comentarios de documentación a todas las funciones, métodos, clases y módulos públicos en la base de código.
### [`find-security-vulnerabilities.hbs`](templates/find-security-vulnerabilities.hbs)
Usa esta plantilla para generar prompts para encontrar posibles vulnerabilidades de seguridad en la base de código. Buscará problemas de seguridad comunes y proporcionará recomendaciones sobre cómo solucionarlos o mitigarlos.
### [`clean-up-code.hbs`](templates/clean-up-code.hbs)
Usa esta plantilla para generar prompts para limpiar y mejorar la calidad del código. Buscará oportunidades para mejorar la legibilidad, adherencia a las mejores prácticas, eficiencia, manejo de errores, y más.
### [`fix-bugs.hbs`](templates/fix-bugs.hbs)
Usa esta plantilla para generar prompts para corregir errores en la base de código. Ayudará a diagnosticar problemas, proporcionar sugerencias de corrección y actualizar el código con las correcciones propuestas.
### [`write-github-pull-request.hbs`](templates/write-github-pull-request.hbs)
Usa esta plantilla para crear una descripción de Pull Request de GitHub en markdown comparando el git diff y el git log de dos ramas.
### [`write-github-readme.hbs`](templates/write-github-readme.hbs)
Usa esta plantilla para generar un archivo README de alta calidad para el proyecto, adecuado para alojar en GitHub. Analizará la base de código para entender su propósito y funcionalidad, y generará el contenido del README en formato Markdown.
### [`write-git-commit.hbs`](templates/write-git-commit.hbs)
Usa esta plantilla para generar commits de git a partir de los archivos en estado staged en tu directorio git. Analizará la base de código para entender su propósito y funcionalidad, y generará el contenido del mensaje de commit de git en formato Markdown.
### [`improve-performance.hbs`](templates/improve-performance.hbs)
Usa esta plantilla para generar prompts para mejorar el rendimiento de la base de código. Buscará oportunidades de optimización, proporcionará sugerencias específicas y actualizará el código con los cambios.
Puedes usar estas plantillas pasando el flag `-t` seguido de la ruta al archivo de plantilla. Por ejemplo:
```sh
code2prompt path/to/codebase -t templates/document-the-code.hbs
```
## Variables Definidas por el Usuario
`code2prompt` soporta el uso de variables definidas por el usuario en las plantillas de Handlebars. Cualquier variable en la plantilla que no sea parte del contexto predeterminado (`absolute_code_path`, `source_tree`, `files`) será tratada como una variable definida por el usuario.
Durante la generación del prompt, `code2prompt` solicitará al usuario que ingrese valores para estas variables definidas por el usuario. Esto permite una mayor personalización de los prompts generados basados en la entrada del usuario.
Por ejemplo, si tu plantilla incluye `{{challenge_name}}` y `{{challenge_description}}`, se te pedirá que ingreses valores para estas variables al ejecutar `code2prompt`.
Esta característica permite crear plantillas reutilizables que pueden adaptarse a diferentes escenarios basados en la información proporcionada por el usuario.
## Tokenizadores
La tokenización se implementa usando [`tiktoken-rs`](https://github.com/zurawiki/tiktoken-rs). `tiktoken` soporta estas codificaciones utilizadas por los modelos de OpenAI:
| Nombre de codificación | Modelos de OpenAI |
| ----------------------- | ------------------------------------------------------------------------- |
| `cl100k_base` | Modelos de ChatGPT, `text-embedding-ada-002` |
| `p50k_base` | Modelos de código, `text-davinci-002`, `text-davinci-003` |
| `p50k_edit` | Usar para modelos de edición como `text-davinci-edit-001`, `code-davinci-edit-001` |
| `r50k_base` (o `gpt2`) | Modelos GPT-3 como `davinci` |
| `o200k_base` | Modelos GPT-4o |
Para más contexto sobre los diferentes tokenizadores, ver el [OpenAI Cookbook](https://github.com/openai/openai-cookbook/blob/66b988407d8d13cad5060a881dc8c892141f2d5c/examples/How_to_count_tokens_with_tiktoken.ipynb)
## ¿Cómo es útil?
`code2prompt` facilita la generación de prompts para LLMs desde tu base de código. Recorre el directorio, construye una estructura de árbol y recopila información sobre cada archivo. Puedes personalizar la generación de prompts usando plantillas de Handlebars. El prompt generado se copia automáticamente en tu portapapeles y también se puede guardar en un archivo de salida. `code2prompt` ayuda a agilizar el proceso de creación de prompts para análisis de código, generación y otras tareas.
## Contribución
Formas de contribuir:
- Sugerir una característica
- Reportar un error
- Arreglar algo y abrir un pull request
- Ayudarme a documentar el código
- Difundir la palabra
## Licencia
Licenciado bajo la Licencia MIT, ver LICENSE para más información.
## ¿Te gustó el proyecto?
Si te gustó el proyecto y lo encontraste útil, por favor dale una :star: y considera apoyar a los autores!
================================================
FILE: crates/code2prompt/Cargo.toml
================================================
[package]
name = "code2prompt"
version = "4.2.0"
edition = "2024"
description = "Command-line interface for code2prompt"
license = "MIT"
repository = "https://github.com/mufeedvh/code2prompt"
readme = "../../README.md"
[features]
wayland = ["arboard/wayland-data-control"]
[dependencies]
code2prompt_core = { path = "../code2prompt-core", version = "4.2.0" }
clap = { workspace = true }
env_logger = { workspace = true }
arboard = { workspace = true }
anyhow = { workspace = true }
colored = { workspace = true }
indicatif = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
inquire = { workspace = true }
terminal_size = { workspace = true }
lscolors = { workspace = true }
ansi_term = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
tokio = { workspace = true }
tui-tree-widget = { workspace = true }
tui-textarea = { workspace = true }
walkdir = { workspace = true }
unicode-width = { workspace = true }
bracoxide = { workspace = true }
git2 = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
regex = { workspace = true }
handlebars = { workspace = true }
ignore = { workspace = true }
tiktoken-rs = { workspace = true }
[target.'cfg(windows)'.dependencies]
winapi = { workspace = true }
[[bin]]
name = "code2prompt"
path = "src/main.rs"
[dev-dependencies]
tempfile = "3.24"
assert_cmd = "2.1.1"
predicates = "3.1"
env_logger = "0.11.3"
rstest = "0.26"
================================================
FILE: crates/code2prompt/src/args.rs
================================================
//! Command-line argument parsing and validation.
//!
//! This module defines the CLI structure using clap for parsing command-line arguments
//! and options for the code2prompt tool. It supports both TUI and CLI modes with
//! comprehensive configuration options for file selection, output formatting,
//! tokenization, and git integration.
use anyhow::{Result, anyhow};
use clap::{Parser, builder::ValueParser};
use code2prompt_core::{
sort::FileSortMethod, template::OutputFormat, tokenizer::TokenFormat, tokenizer::TokenizerType,
};
use serde::de::DeserializeOwned;
use std::path::PathBuf;
// ~~~ CLI Arguments ~~~
#[derive(Parser, Debug)]
#[clap(
name = env!("CARGO_PKG_NAME"),
version = env!("CARGO_PKG_VERSION"),
author = env!("CARGO_PKG_AUTHORS")
)]
#[command(arg_required_else_help = true)]
pub struct Cli {
/// Path to the codebase directory
#[arg(value_name = "PATH_TO_ANALYZE", default_value = ".")]
pub path: PathBuf,
/// Optional output file (use "-" for stdout)
#[arg(short = 'O', long = "output-file", value_name = "FILE")]
pub output_file: Option,
/// Launch the Terminal User Interface
#[clap(long)]
pub tui: bool,
/// Patterns to include
#[clap(short = 'i', long = "include")]
pub include: Vec,
/// Patterns to exclude
#[clap(short = 'e', long = "exclude")]
pub exclude: Vec,
/// Output format
#[clap(
short = 'F',
long = "output-format",
value_name = "markdown, json, xml",
value_parser = ValueParser::new(parse_serde::)
)]
pub output_format: Option,
/// Optional Path to a custom Handlebars template
#[clap(short, long, value_name = "TEMPLATE")]
pub template: Option,
/// List the full directory tree
#[clap(long)]
pub full_directory_tree: bool,
/// Token encoding to use for token count
#[clap(
long,
value_name = "cl100k, p50k, p50k_edit, r50k",
value_parser = ValueParser::new(parse_serde::),
)]
pub encoding: Option,
/// Display the token count of the generated prompt. Accepts a format: "raw" (machine parsable) or "format" (human readable)
#[clap(
long,
value_name = "raw,format",
value_parser = ValueParser::new(parse_serde::),
)]
pub token_format: Option,
/// Include git diff
#[clap(short, long)]
pub diff: bool,
/// Generate git diff between two branches
#[clap(long, value_name = "BRANCHES", num_args = 2, value_delimiter = ',')]
pub git_diff_branch: Option>,
/// Retrieve git log between two branches
#[clap(long, value_name = "BRANCHES", num_args = 2, value_delimiter = ',')]
pub git_log_branch: Option>,
/// Add line numbers to the source code
#[clap(short, long)]
pub line_numbers: bool,
/// If true, paths in the output will be absolute instead of relative.
#[clap(long)]
pub absolute_paths: bool,
/// Follow symlinks
#[clap(short = 'L', long)]
pub follow_symlinks: bool,
/// Include hidden directories and files
#[clap(long)]
pub hidden: bool,
/// Disable wrapping code inside markdown code blocks
#[clap(long)]
pub no_codeblock: bool,
/// Copy output to clipboard
#[clap(short = 'c', long)]
pub clipboard: bool,
/// Optional Disable copying to clipboard (deprecated, use default behavior)
#[clap(long, hide = true)]
pub no_clipboard: bool,
/// Skip .gitignore rules
#[clap(long)]
pub no_ignore: bool,
/// Sort order for files
#[clap(
long,
value_name = "name_asc, name_desc, date_asc, date_desc",
value_parser = ValueParser::new(parse_serde::),
)]
pub sort: Option,
/// Suppress progress and success messages
#[clap(short = 'q', long)]
pub quiet: bool,
/// Display a visual token map of files (similar to disk usage tools)
#[clap(long)]
pub token_map: bool,
/// Maximum number of lines to display in token map (default: terminal height - 10)
#[clap(long, value_name = "NUMBER")]
pub token_map_lines: Option,
/// Minimum percentage of tokens to display in token map (default: 0.1%)
#[clap(long, value_name = "PERCENT")]
pub token_map_min_percent: Option,
/// Start with all files deselected
#[clap(long)]
pub deselected: bool,
#[arg(long, hide = true)]
pub clipboard_daemon: bool,
}
/// Helper function to parse serde deserializable enum from string inputs.
fn parse_serde(s: &str) -> Result {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.map_err(|e| anyhow!("Failed to parse value: {}", e))
}
================================================
FILE: crates/code2prompt/src/clipboard.rs
================================================
use anyhow::{Context, Result};
#[cfg(not(target_os = "linux"))]
/// Copies the provided text to the system clipboard.
///
/// This is a simple, one-shot copy operation suitable for non-Linux platforms
/// or scenarios where maintaining the clipboard content is not required.
///
/// # Arguments
///
/// * `text` - The text content to be copied.
///
/// # Returns
///
/// * `Result<()>` - Returns Ok on success, or an error if the clipboard could not be accessed.
pub fn copy_text_to_clipboard(text: &str) -> Result<()> {
use arboard::Clipboard;
match Clipboard::new() {
Ok(mut clipboard) => {
clipboard
.set_text(text.to_string())
.context("Failed to copy to clipboard")?;
Ok(())
}
Err(e) => Err(anyhow::anyhow!("Failed to initialize clipboard: {}", e)),
}
}
#[cfg(target_os = "linux")]
/// Entry point for the clipboard daemon process on Linux.
///
/// This function reads clipboard content from its standard input, sets it as the system clipboard,
/// and then waits to serve clipboard requests. This ensures that the clipboard content remains available
/// even after the main application exits. The daemon will exit automatically once the clipboard is overwritten.
///
/// # Returns
///
/// * `Result<()>` - Returns Ok on success or an error if clipboard operations fail.
pub fn serve_clipboard_daemon() -> Result<()> {
use arboard::{Clipboard, LinuxClipboardKind, SetExtLinux};
use std::io::Read;
// Read content from stdin
let mut content_from_stdin = String::new();
std::io::stdin()
.read_to_string(&mut content_from_stdin)
.context("Failed to read from stdin")?;
// Initialize the clipboard
let mut clipboard = Clipboard::new().context("Failed to initialize clipboard")?;
// Explicitly set the clipboard selection to Clipboard (not Primary)
clipboard
.set()
.clipboard(LinuxClipboardKind::Clipboard)
.wait()
.text(content_from_stdin)
.context("Failed to set clipboard content")?;
Ok(())
}
#[cfg(target_os = "linux")]
/// Spawns a daemon process to maintain clipboard content on Linux.
///
/// On Linux (Wayland/X11), the clipboard content is owned by the process that defined it.
/// If the main application exits, the clipboard would be cleared.
/// To avoid this, this function spawns a new process that will run in the background
/// (daemon) and maintain the clipboard content until it is overwritten by a new copy.
///
/// # Arguments
///
/// * `text` - The text to be served by the daemon process.
///
/// # Returns
///
/// * `Result<()>` - Returns Ok if the daemon process was spawned and the content was sent successfully,
/// or an error if the process could not be launched or written to.
pub fn spawn_clipboard_daemon(content: &str) -> Result<()> {
use std::process::{Command, Stdio};
use log::info;
// ~~~ Setting up the command to run the daemon ~~~
let current_exe: std::path::PathBuf =
std::env::current_exe().context("Failed to get current executable path")?;
let mut args: Vec = std::env::args().collect();
args.push("--clipboard-daemon".to_string());
// ~~~ Spawn the clipboard daemon process ~~~
let mut child = Command::new(current_exe)
.args(&args[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to launch clipboard daemon process")?;
// ~~~ Write the content to the daemon's standard input ~~~
use std::io::Write;
let mut stdin = child
.stdin
.take()
.context("Failed to acquire stdin pipe for clipboard daemon process")?;
stdin
.write_all(content.as_bytes())
.context("Failed to write content to clipboard daemon process")?;
info!("Clipboard daemon launched successfully");
Ok(())
}
/// Copy text to clipboard
pub fn copy_to_clipboard(text: &str) -> Result<()> {
#[cfg(target_os = "linux")]
{
spawn_clipboard_daemon(text)
}
#[cfg(not(target_os = "linux"))]
{
copy_text_to_clipboard(text)
}
}
================================================
FILE: crates/code2prompt/src/config.rs
================================================
//! Configuration parsing and session creation utilities.
//!
//! This module handles the conversion of command-line arguments into
//! Code2PromptSession instances, consolidating all configuration parsing
//! logic in one place for better maintainability and separation of concerns.
use anyhow::{Context, Result};
use code2prompt_core::{
configuration::Code2PromptConfig,
session::Code2PromptSession,
sort::FileSortMethod,
template::{OutputFormat, extract_undefined_variables},
tokenizer::TokenizerType,
};
use inquire::Text;
use log::error;
use std::path::PathBuf;
use crate::{args::Cli, config_loader::ConfigSource};
/// Unified session builder that merges configuration layering in one place
/// - base: Some(&ConfigSource) to use loaded config as defaults; None to use CLI defaults
/// - args: CLI arguments
/// - tui_mode: whether running in TUI mode (enables token map by default)
pub fn build_session(
base: Option<&ConfigSource>,
args: &Cli,
tui_mode: bool,
) -> Result {
let mut configuration = Code2PromptConfig::builder();
let cfg = base.map(|b| &b.config);
// Path: config path takes precedence if provided, otherwise CLI path
if let Some(c) = cfg {
if let Some(path) = &c.path {
configuration.path(PathBuf::from(path));
} else {
configuration.path(args.path.clone());
}
} else {
configuration.path(args.path.clone());
}
// Include/Exclude patterns:
// If CLI provides any patterns, they override config patterns completely (to avoid conflicts)
let use_cli_patterns = !args.include.is_empty() || !args.exclude.is_empty();
let (include_patterns, exclude_patterns) = if use_cli_patterns {
(
expand_comma_separated_patterns(&args.include),
expand_comma_separated_patterns(&args.exclude),
)
} else if let Some(c) = cfg {
(c.include_patterns.clone(), c.exclude_patterns.clone())
} else {
(
expand_comma_separated_patterns(&args.include),
expand_comma_separated_patterns(&args.exclude),
)
};
configuration
.include_patterns(include_patterns)
.exclude_patterns(exclude_patterns);
// Display options: CLI overrides config (logical-or semantics for booleans)
let cfg_line_numbers = cfg.map(|c| c.line_numbers).unwrap_or(false);
let cfg_absolute = cfg.map(|c| c.absolute_path).unwrap_or(false);
let cfg_full_tree = cfg.map(|c| c.full_directory_tree).unwrap_or(false);
configuration
.line_numbers(args.line_numbers || cfg_line_numbers)
.absolute_path(args.absolute_paths || cfg_absolute)
.full_directory_tree(args.full_directory_tree || cfg_full_tree);
// Output format: CLI overrides config
let output_format = if let Some(output_format_str) = args.output_format {
output_format_str
} else if let Some(c) = cfg {
c.output_format.unwrap_or(OutputFormat::Markdown)
} else {
OutputFormat::Markdown
};
configuration.output_format(output_format);
// Sort method: CLI overrides config
let sort_method = if let Some(sort_str) = args.sort {
sort_str
} else if let Some(c) = cfg {
c.sort_method.unwrap_or(FileSortMethod::NameAsc)
} else {
FileSortMethod::NameAsc
};
configuration.sort_method(sort_method);
// Tokenizer settings: CLI overrides config
let tokenizer_type = if let Some(encoding) = args.encoding {
encoding
} else if let Some(c) = cfg {
c.encoding.unwrap_or(TokenizerType::Cl100kBase)
} else {
TokenizerType::Cl100kBase
};
// Token format: CLI overrides config
let token_format = if let Some(format) = args.token_format {
format
} else if let Some(c) = cfg {
c.token_format
.unwrap_or(code2prompt_core::tokenizer::TokenFormat::Format)
} else {
code2prompt_core::tokenizer::TokenFormat::Format
};
configuration
.encoding(tokenizer_type)
.token_format(token_format);
// Template: CLI overrides config
let (template_str, template_name) = if args.template.is_some() {
parse_template(&args.template).map_err(|e| {
error!("Failed to parse template: {}", e);
e
})?
} else if let Some(c) = cfg {
(
c.template_str.clone().unwrap_or_default(),
c.template_name
.clone()
.unwrap_or_else(|| "default".to_string()),
)
} else {
("".to_string(), "default".to_string())
};
configuration
.template_str(template_str)
.template_name(template_name);
// Git options: CLI overrides config
let diff_branches = parse_branch_argument(&args.git_diff_branch).or_else(|| {
cfg.and_then(|c| {
c.diff_branches.as_ref().and_then(|branches| {
if branches.len() == 2 {
Some((branches[0].clone(), branches[1].clone()))
} else {
None
}
})
})
});
let log_branches = parse_branch_argument(&args.git_log_branch).or_else(|| {
cfg.and_then(|c| {
c.log_branches.as_ref().and_then(|branches| {
if branches.len() == 2 {
Some((branches[0].clone(), branches[1].clone()))
} else {
None
}
})
})
});
let cfg_diff_enabled = cfg.map(|c| c.diff_enabled).unwrap_or(false);
let cfg_token_map_enabled = cfg.map(|c| c.token_map_enabled).unwrap_or(false);
let cfg_deselected = cfg.map(|c| c.deselected).unwrap_or(false);
configuration
.diff_enabled(args.diff || cfg_diff_enabled)
.diff_branches(diff_branches)
.log_branches(log_branches)
.no_ignore(args.no_ignore)
.hidden(args.hidden)
.no_codeblock(args.no_codeblock)
.follow_symlinks(args.follow_symlinks)
.token_map_enabled(args.token_map || cfg_token_map_enabled || tui_mode)
.deselected(args.deselected || cfg_deselected);
// User variables from config (if available)
if let Some(c) = cfg {
configuration.user_variables(c.user_variables.clone());
}
let session = Code2PromptSession::new(configuration.build()?);
Ok(session)
}
/// Parses the branch argument from command line options.
///
/// Takes an optional vector of strings and converts it to a tuple of two branch names
/// if exactly two branches are provided.
///
/// # Arguments
///
/// * `branch_arg` - An optional vector containing branch names
///
/// # Returns
///
/// * `Option<(String, String)>` - A tuple of (from_branch, to_branch) if two branches were provided, None otherwise
pub fn parse_branch_argument(branch_arg: &Option>) -> Option<(String, String)> {
match branch_arg {
Some(branches) if branches.len() == 2 => Some((branches[0].clone(), branches[1].clone())),
_ => None,
}
}
/// Loads a template from a file path or returns default values.
///
/// # Arguments
///
/// * `template_arg` - An optional path to a template file
///
/// # Returns
///
/// * `Result<(String, String)>` - A tuple containing (template_content, template_name)
/// where template_name is "custom" for user-provided templates or "default" otherwise
pub fn parse_template(template_arg: &Option) -> Result<(String, String)> {
match template_arg {
Some(path) => {
let template_str =
std::fs::read_to_string(path).context("Failed to load custom template file")?;
Ok((template_str, "custom".to_string()))
}
None => Ok(("".to_string(), "default".to_string())),
}
}
/// Handles user-defined variables in the template and adds them to the session.
///
/// This function extracts undefined variables from the template and prompts
/// the user to provide values for them through interactive input.
///
/// # Arguments
///
/// * `session` - The Code2PromptSession to modify
/// * `template_content` - The template content string to analyze
///
/// # Returns
///
/// * `Result<()>` - An empty result indicating success or an error
pub fn handle_undefined_variables(
session: &mut Code2PromptSession,
template_content: &str,
) -> Result<()> {
let undefined_variables = extract_undefined_variables(template_content);
for var in undefined_variables.iter() {
// Check if variable is already defined in user_variables
if !session.config.user_variables.contains_key(var) {
let prompt = format!("Enter value for '{}': ", var);
let answer = Text::new(&prompt)
.with_help_message("Fill user defined variable in template")
.prompt()
.unwrap_or_default();
session.config.user_variables.insert(var.clone(), answer);
}
}
Ok(())
}
/// Expands comma-separated patterns while preserving brace expansion patterns
///
/// This function handles the expansion of comma-separated include/exclude patterns
/// while being careful not to split patterns that contain brace expansion syntax.
///
/// # Arguments
///
/// * `patterns` - A vector of pattern strings that may contain comma-separated values
///
/// # Returns
///
/// * `Vec` - A vector of individual patterns
fn expand_comma_separated_patterns(patterns: &[String]) -> Vec {
let mut expanded = Vec::new();
for pattern in patterns {
// If the pattern contains braces, don't split on commas (preserve brace expansion)
if pattern.contains('{') && pattern.contains('}') {
expanded.push(pattern.clone());
} else {
// Split on commas for regular patterns
for part in pattern.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() {
expanded.push(trimmed.to_string());
}
}
}
}
expanded
}
================================================
FILE: crates/code2prompt/src/config_loader.rs
================================================
//! Configuration file loading and management.
//!
//! This module handles loading TOML configuration files from multiple locations
//! with proper priority handling and informational messages.
use anyhow::{Context, Result};
use code2prompt_core::configuration::{OutputDestination, TomlConfig};
use colored::*;
use log::{debug, info};
use std::path::Path;
/// Configuration source information
#[derive(Debug, Clone)]
pub struct ConfigSource {
pub config: TomlConfig,
}
/// Load configuration with proper priority handling
pub fn load_config(quiet: bool) -> Result {
// Check for local config first (.c2pconfig in current directory)
let local_config_path = std::env::current_dir()?.join(".c2pconfig");
if local_config_path.exists() {
match load_config_from_file(&local_config_path) {
Ok(config) => {
if !quiet {
eprintln!(
"{}{}{} Using config from: {}",
"[".bold().white(),
"i".bold().blue(),
"]".bold().white(),
local_config_path.display()
);
}
info!("Loaded local config from: {}", local_config_path.display());
return Ok(ConfigSource { config });
}
Err(e) => {
debug!("Failed to load local config: {}", e);
}
}
}
// Check for global config (~/.config/code2prompt/.c2pconfig)
if let Some(config_dir) = dirs::config_dir() {
let global_config_path = config_dir.join("code2prompt").join(".c2pconfig");
if global_config_path.exists() {
match load_config_from_file(&global_config_path) {
Ok(config) => {
if !quiet {
eprintln!(
"{}{}{} Using config from: {}",
"[".bold().white(),
"i".bold().blue(),
"]".bold().white(),
global_config_path.display()
);
}
info!(
"Loaded global config from: {}",
global_config_path.display()
);
return Ok(ConfigSource { config });
}
Err(e) => {
debug!("Failed to load global config: {}", e);
}
}
}
}
// Use default configuration
if !quiet {
eprintln!(
"{}{}{} Using default configuration",
"[".bold().white(),
"i".bold().blue(),
"]".bold().white(),
);
}
info!("Using default configuration");
Ok(ConfigSource {
config: TomlConfig::default(),
})
}
/// Load TOML configuration from a file
fn load_config_from_file(path: &Path) -> Result {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
TomlConfig::from_toml_str(&content)
.with_context(|| format!("Failed to parse TOML config file: {}", path.display()))
}
/// Get the default output destination from config
pub fn get_default_output_destination(config_source: &ConfigSource) -> OutputDestination {
config_source.config.default_output.clone()
}
================================================
FILE: crates/code2prompt/src/main.rs
================================================
//! code2prompt is a command-line tool to generate an LLM prompt from a codebase directory.
//!
//! Authors: Olivier D'Ancona (@ODAncona), Mufeed VH (@mufeedvh)
mod args;
mod clipboard;
mod config;
mod config_loader;
mod model;
mod token_map;
mod tui;
mod utils;
mod view;
mod widgets;
use crate::utils::format_number;
use anyhow::{Context, Result};
use args::Cli;
use clap::Parser;
use code2prompt_core::template::write_to_file;
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use log::{debug, error, info};
use std::io::Write;
use tui::run_tui;
#[tokio::main]
async fn main() -> Result<()> {
env_logger::init();
info! {"Args: {:?}", std::env::args().collect::>()};
let args: Cli = Cli::parse();
// ~~~ Clipboard Daemon ~~~
#[cfg(target_os = "linux")]
{
use clipboard::serve_clipboard_daemon;
if args.clipboard_daemon {
info! {"Serving clipboard daemon..."};
serve_clipboard_daemon()?;
info! {"Shutting down gracefully..."};
return Ok(());
}
}
// ~~~ TUI or CLI Mode ~~~
if args.tui {
// ~~~ Build Session for TUI ~~~
let session = config::build_session(None, &args, args.tui).unwrap_or_else(|e| {
error!("Failed to create session: {}", e);
std::process::exit(1);
});
run_tui(session).await
} else {
run_cli_mode_with_args(args).await
}
}
/// Run the CLI mode with parsed arguments
async fn run_cli_mode_with_args(args: Cli) -> Result<()> {
use code2prompt_core::configuration::OutputDestination;
use config_loader::{get_default_output_destination, load_config};
let quiet_mode = args.quiet;
// ~~~ Load Configuration ~~~
let config_source = load_config(quiet_mode)?; // load config files first (local > global), then apply CLI args on top
// ~~~ Build Session with config + CLI args ~~~
let mut session = config::build_session(Some(&config_source), &args, false)?;
// ~~~ Determine Output Behavior ~~~
let default_output = get_default_output_destination(&config_source);
// Determine final output destinations (Solution B: Unix-style behavior)
let output_to_clipboard = if args.clipboard {
// Explicit clipboard flag - ONLY clipboard, no stdout
true
} else if args.output_file.is_some() {
// Output file specified, don't use clipboard unless explicitly requested
false
} else {
// Use config default
matches!(default_output, OutputDestination::Clipboard)
};
let output_to_stdout = if args.clipboard {
false
} else if let Some(ref output_file) = args.output_file {
output_file == "-"
} else {
match default_output {
OutputDestination::Stdout => true,
OutputDestination::Clipboard => false,
OutputDestination::File => false,
}
};
// ~~~ Create Session ~~~
let spinner = if !quiet_mode {
Some(setup_spinner("Traversing directory and building tree..."))
} else {
None
};
// ~~~ Gather Repository Data ~~~
session.load_codebase().map_err(|e| {
if let Some(s) = spinner.as_ref() {
s.finish_with_message("Failed!".red().to_string())
}
error!("Failed to build directory tree: \n{}", e);
anyhow::anyhow!("Failed to build directory tree: {}", e)
})?;
if let Some(s) = spinner.as_ref() {
s.set_message("Proceeding…")
}
// ~~~ Git Related ~~~
// Git Diff
if session.config.diff_enabled {
if let Some(s) = spinner.as_ref() {
s.set_message("Generating git diff...")
}
session.load_git_diff().unwrap_or_else(|e| {
if let Some(s) = spinner.as_ref() {
s.finish_with_message("Failed!".red().to_string())
}
error!("Failed to generate git diff: {}", e);
std::process::exit(1);
});
}
// Load Git diff between branches if provided
if session.config.diff_branches.is_some() {
if let Some(s) = spinner.as_ref() {
s.set_message("Generating git diff between two branches...")
}
session
.load_git_diff_between_branches()
.unwrap_or_else(|e| {
if let Some(s) = spinner.as_ref() {
s.finish_with_message("Failed!".red().to_string())
}
error!("Failed to generate git diff: {}", e);
std::process::exit(1);
});
}
// Load Git log between branches if provided
if session.config.log_branches.is_some() {
if let Some(ref s) = spinner {
s.set_message("Generating git log between two branches...");
}
session.load_git_log_between_branches().unwrap_or_else(|e| {
if let Some(ref s) = spinner {
s.finish_with_message("Failed!".red().to_string());
}
error!("Failed to generate git log: {}", e);
std::process::exit(1);
});
}
// ~~~ Template ~~~
// Handle undefined variables (modifies session.config.user_variables)
let template_str_clone = session.config.template_str.clone();
config::handle_undefined_variables(&mut session, &template_str_clone)?;
// Data - now build after handling undefined variables
let data = session.build_template_data();
debug!(
"Template Context: absolute_code_path={}, files_count={}, has_user_vars={}",
data.absolute_code_path,
data.files.map(|f| f.len()).unwrap_or(0),
!session.config.user_variables.is_empty()
);
// Render
let rendered = session.render_prompt(&data).unwrap_or_else(|e| {
error!("Failed to render prompt: {}", e);
std::process::exit(1);
});
if let Some(ref s) = spinner {
s.finish_with_message("Codebase Traversal Done!".green().to_string());
}
// ~~~ Token Count ~~~
let token_count = rendered.token_count;
let formatted_token_count = format_number(token_count, &session.config.token_format);
let model_info = rendered.model_info;
if !quiet_mode {
eprintln!(
"{}{}{} Token count: {}, Model info: {}",
"[".bold().white(),
"i".bold().blue(),
"]".bold().white(),
formatted_token_count,
model_info
);
}
// ~~~ Token Map Display ~~~
if args.token_map {
use crate::token_map::{display_token_map, generate_token_map_with_limit};
if let Some(files) = session.data.files.as_ref() {
// Calculate total tokens from individual file counts
let total_from_files: usize = files.iter().map(|f| f.token_count).sum();
// Get max lines from command line or calculate from terminal height
let max_lines = args.token_map_lines.unwrap_or_else(|| {
terminal_size::terminal_size()
.map(|(_, terminal_size::Height(h))| {
let height = h as usize;
// Ensure minimum of 10 lines, subtract 10 for other output
if height > 20 { height - 10 } else { 10 }
})
.unwrap_or(20) // Default to 20 lines if terminal size detection fails
});
// Use the sum of individual file tokens for the map with line limit
let entries = generate_token_map_with_limit(
files,
total_from_files,
Some(max_lines),
args.token_map_min_percent,
);
display_token_map(&entries, total_from_files);
}
}
// ~~~ Output to Stdout ~~~
if output_to_stdout {
print!("{}", &rendered.prompt);
std::io::stdout()
.flush()
.context("Failed to flush stdout")?;
}
// ~~~ Copy to Clipboard ~~~
if output_to_clipboard {
use crate::clipboard::copy_to_clipboard;
match copy_to_clipboard(&rendered.prompt) {
Ok(_) => {
if !quiet_mode {
eprintln!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
"Copied to clipboard successfully.".green()
);
}
}
Err(e) => {
if !quiet_mode {
eprintln!(
"{}{}{} {}",
"[".bold().white(),
"!".bold().red(),
"]".bold().white(),
format!("Failed to copy to clipboard: {}", e).red()
);
}
}
}
}
// ~~~ Output File ~~~
if let Some(ref output_file) = args.output_file
&& output_file != "-"
{
output_prompt(
Some(std::path::Path::new(output_file)),
&rendered.prompt,
quiet_mode,
)?;
}
Ok(())
}
/// Sets up a progress spinner with a given message
///
/// # Arguments
///
/// * `message` - A message to display with the spinner
///
/// # Returns
///
/// * `ProgressBar` - The configured progress spinner
fn setup_spinner(message: &str) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(std::time::Duration::from_millis(220));
let done_symbol = format!(
"{}{}{}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white()
);
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&[
"▹▹▹▹▹",
"▸▹▹▹▹",
"▹▸▹▹▹",
"▹▹▸▹▹",
"▹▹▹▸▹",
"▹▹▹▹▸",
&done_symbol,
])
.template("{spinner:.blue} {msg}")
.unwrap(),
);
spinner.set_message(message.to_string());
spinner
}
// ~~~ Output to file or stdout ~~~
fn output_prompt(
effective_output: Option<&std::path::Path>,
rendered: &str,
quiet: bool,
) -> Result<()> {
let output_path = match effective_output {
Some(path) => path,
None => return Ok(()), // nothing to do
};
let path_str = output_path.to_string_lossy();
if path_str == "-" {
// stdout
print!("{}", rendered);
std::io::stdout()
.flush()
.context("Failed to flush stdout")?;
} else {
// file
write_to_file(&path_str, rendered)
.context(format!("Failed to write to file: {}", path_str))?;
if !quiet {
eprintln!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
format!("Prompt written to file: {}", path_str).green()
);
}
}
Ok(())
}
================================================
FILE: crates/code2prompt/src/model/commands.rs
================================================
//! Command system for handling side effects in the Model-View-Update architecture.
//!
//! This module implements the Cmd pattern from Elm/Redux, allowing the Model::update()
//! function to remain pure while still triggering side effects like async operations,
//! file I/O, and clipboard operations.
use std::collections::HashMap;
/// Commands represent side effects that should be executed after model updates.
/// This allows Model::update() to remain pure while still triggering necessary
/// side effects like async operations, file I/O, etc.
#[derive(Debug, Clone)]
pub enum Cmd {
/// No command - pure state update only
None,
/// Run analysis in background
RunAnalysis {
template_content: String,
user_variables: HashMap,
},
/// Copy text to clipboard
CopyToClipboard(String),
/// Save text to file
SaveToFile { filename: String, content: String },
/// Save template to custom directory
SaveTemplate { filename: String, content: String },
/// Refresh file tree from session
RefreshFileTree,
}
================================================
FILE: crates/code2prompt/src/model/mod.rs
================================================
//! Data structures and application state management for the TUI.
//!
//! This module contains the core data structures that represent the application state,
//! including the main Model struct, tab definitions, message types for event handling,
//! and all state management submodules. It serves as the central state container
//! for the terminal user interface.
pub mod commands;
pub mod prompt_output;
pub mod settings;
pub mod statistics;
pub mod template;
pub use commands::*;
pub use prompt_output::*;
pub use settings::*;
pub use statistics::*;
pub use template::*;
use crate::utils::directory_contains_selected_files;
use code2prompt_core::session::Code2PromptSession;
/// The five main tabs of the TUI
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
FileTree,
Settings,
Statistics,
Template,
PromptOutput,
}
/// Input mode for the FileTree tab
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileTreeInputMode {
Normal,
Search,
}
/// Hierarchical file node for TUI display with proper parent-child relationships
#[derive(Debug, Clone)]
pub struct DisplayFileNode {
pub path: std::path::PathBuf,
pub name: String,
pub is_directory: bool,
pub is_expanded: bool,
pub level: usize,
pub children_loaded: bool,
pub children: Vec,
}
impl DisplayFileNode {
pub fn new(path: std::path::PathBuf, level: usize) -> Self {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let is_directory = path.is_dir();
Self {
path,
name,
is_directory,
is_expanded: false,
level,
children_loaded: false,
children: Vec::new(),
}
}
/// Find a node by path in the tree (recursive)
pub fn find_node_mut(&mut self, target_path: &std::path::Path) -> Option<&mut DisplayFileNode> {
if self.path == target_path {
return Some(self);
}
for child in &mut self.children {
if let Some(found) = child.find_node_mut(target_path) {
return Some(found);
}
}
None
}
/// Load children for this directory node
pub fn load_children(
&mut self,
session: &mut code2prompt_core::session::Code2PromptSession,
) -> Result<(), Box> {
if !self.is_directory || self.children_loaded {
return Ok(());
}
self.children.clear();
// Use ignore crate to respect gitignore
use ignore::WalkBuilder;
let walker = WalkBuilder::new(&self.path).max_depth(Some(1)).build();
for entry in walker {
let entry = entry?;
let path = entry.path();
if path == self.path {
continue; // Skip self
}
let mut child = DisplayFileNode::new(path.to_path_buf(), self.level + 1);
// Auto-expand if contains selected files
if child.is_directory && directory_contains_selected_files(&child.path, session) {
child.is_expanded = true;
}
self.children.push(child);
}
// Sort children: directories first, then alphabetically
self.children
.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
self.children_loaded = true;
Ok(())
}
}
/// Messages for updating the model
#[derive(Debug, Clone)]
pub enum Message {
SwitchTab(Tab),
Quit,
UpdateSearchQuery(String),
ToggleFileSelection(usize),
ExpandDirectory(usize),
CollapseDirectory(usize),
MoveTreeCursor(i32),
RefreshFileTree,
EnterSearchMode,
ExitSearchMode,
MoveSettingsCursor(i32),
ToggleSetting(usize),
CycleSetting(usize),
RunAnalysis,
AnalysisComplete(AnalysisResults),
AnalysisError(String),
CopyToClipboard,
SaveToFile(String),
ScrollOutput(i16),
CycleStatisticsView(i8),
ScrollStatistics(i16),
SaveTemplate(String),
ReloadTemplate,
LoadTemplate,
RefreshTemplates,
SetTemplateFocus(TemplateFocus, FocusMode),
SetTemplateFocusMode(FocusMode),
TemplateEditorInput(ratatui::crossterm::event::KeyEvent),
TemplatePickerMove(i32),
VariableStartEditing(String),
VariableInputChar(char),
VariableInputBackspace,
VariableInputEnter,
VariableInputCancel,
VariableNavigateUp,
VariableNavigateDown,
}
/// Represents the overall state of the TUI application.
#[derive(Debug, Clone)]
pub struct Model {
pub session: Code2PromptSession,
pub current_tab: Tab,
pub should_quit: bool,
pub file_tree_input_mode: FileTreeInputMode,
pub file_tree_nodes: Vec,
pub search_query: String,
pub tree_cursor: usize,
pub file_tree_scroll: u16,
pub settings: SettingsState,
pub statistics: StatisticsState,
pub template: TemplateState,
pub prompt_output: PromptOutputState,
pub status_message: String,
}
impl Default for Model {
fn default() -> Self {
let config = code2prompt_core::configuration::Code2PromptConfig::default();
let session = Code2PromptSession::new(config);
Model {
session,
current_tab: Tab::FileTree,
should_quit: false,
file_tree_input_mode: FileTreeInputMode::Normal,
file_tree_nodes: Vec::new(),
search_query: String::new(),
tree_cursor: 0,
file_tree_scroll: 0,
settings: SettingsState::default(),
statistics: StatisticsState::default(),
template: TemplateState::default(),
prompt_output: PromptOutputState::default(),
status_message: String::new(),
}
}
}
impl Model {
pub fn new(session: Code2PromptSession) -> Self {
Model {
session,
current_tab: Tab::FileTree,
should_quit: false,
file_tree_input_mode: FileTreeInputMode::Normal,
file_tree_nodes: Vec::new(),
search_query: String::new(),
tree_cursor: 0,
file_tree_scroll: 0,
settings: SettingsState::default(),
statistics: StatisticsState::default(),
template: TemplateState::default(),
prompt_output: PromptOutputState::default(),
status_message: String::new(),
}
}
/// Get grouped settings for display
pub fn get_settings_groups(&self) -> Vec {
crate::view::format_settings_groups(&self.session)
}
pub fn update(&self, message: Message) -> (Self, Cmd) {
let mut new_model = self.clone();
match message {
Message::Quit => {
new_model.should_quit = true;
new_model.status_message = "Goodbye!".to_string();
(new_model, Cmd::None)
}
Message::SwitchTab(tab) => {
new_model.current_tab = tab;
new_model.status_message = format!("Switched to {:?} tab", tab);
(new_model, Cmd::None)
}
Message::RefreshFileTree => {
new_model.status_message = "Refreshing file tree...".to_string();
(new_model, Cmd::RefreshFileTree)
}
Message::UpdateSearchQuery(query) => {
new_model.search_query = query;
new_model.tree_cursor = 0; // Reset cursor when search changes
new_model.file_tree_scroll = 0; // Reset scroll when search changes
(new_model, Cmd::None)
}
Message::EnterSearchMode => {
new_model.file_tree_input_mode = FileTreeInputMode::Search;
new_model.status_message = "Search mode - Type to search, Esc to exit".to_string();
(new_model, Cmd::None)
}
Message::ExitSearchMode => {
new_model.file_tree_input_mode = FileTreeInputMode::Normal;
new_model.status_message = "Exited search mode".to_string();
(new_model, Cmd::None)
}
Message::MoveTreeCursor(delta) => {
let visible_nodes = crate::utils::get_visible_nodes(
&new_model.file_tree_nodes,
&new_model.search_query,
&mut new_model.session,
);
let visible_count = visible_nodes.len();
if visible_count > 0 {
let new_cursor = if delta > 0 {
(new_model.tree_cursor + delta as usize).min(visible_count - 1)
} else {
new_model.tree_cursor.saturating_sub((-delta) as usize)
};
new_model.tree_cursor = new_cursor;
}
(new_model, Cmd::None)
}
Message::MoveSettingsCursor(delta) => {
let settings_count = new_model
.settings
.get_settings_items(&new_model.session)
.len();
if settings_count > 0 {
let new_cursor = if delta > 0 {
(new_model.settings.settings_cursor + delta as usize)
.min(settings_count - 1)
} else {
new_model
.settings
.settings_cursor
.saturating_sub((-delta) as usize)
};
new_model.settings.settings_cursor = new_cursor;
}
(new_model, Cmd::None)
}
Message::ToggleFileSelection(index) => {
let visible_nodes = crate::utils::get_visible_nodes(
&new_model.file_tree_nodes,
&new_model.search_query,
&mut new_model.session,
);
if let Some(display_node) = visible_nodes.get(index) {
let node_path = display_node.node.path.clone();
let name = display_node.node.name.clone();
let is_directory = display_node.node.is_directory;
let current = display_node.is_selected;
// Convert to relative path for session
let relative_path =
if let Ok(rel) = node_path.strip_prefix(&new_model.session.config.path) {
rel.to_path_buf()
} else {
node_path.clone()
};
// Update session selection state (single source of truth)
new_model.session.toggle_file_selection(relative_path);
let action = if current { "Deselected" } else { "Selected" };
let extra = if is_directory { " (and contents)" } else { "" };
new_model.status_message = format!("{} {}{}", action, name, extra);
}
(new_model, Cmd::None)
}
Message::ExpandDirectory(index) => {
let visible_nodes = crate::utils::get_visible_nodes(
&new_model.file_tree_nodes,
&new_model.search_query,
&mut new_model.session,
);
if let Some(display_node) = visible_nodes.get(index)
&& display_node.node.is_directory
{
let node_path = display_node.node.path.clone();
let name = display_node.node.name.clone();
// Ensure the path exists in the tree first
if let Err(e) = crate::utils::ensure_path_exists_in_tree(
&mut new_model.file_tree_nodes,
&node_path,
&mut new_model.session,
) {
new_model.status_message =
format!("Failed to ensure path exists for {}: {}", name, e);
return (new_model, Cmd::None);
}
// Find and expand the node in the tree
let mut found = false;
for root_node in &mut new_model.file_tree_nodes {
if let Some(node) = root_node.find_node_mut(&node_path) {
if !node.is_expanded {
node.is_expanded = true;
// Load children if not already loaded
if !node.children_loaded
&& let Err(e) = node.load_children(&mut new_model.session)
{
new_model.status_message =
format!("Failed to load children for {}: {}", name, e);
return (new_model, Cmd::None);
}
new_model.status_message = format!("Expanded {}", name);
} else {
new_model.status_message = format!("{} is already expanded", name);
}
found = true;
break;
}
}
if !found {
new_model.status_message = format!("Could not find directory {}", name);
}
}
(new_model, Cmd::None)
}
Message::CollapseDirectory(index) => {
let visible_nodes = crate::utils::get_visible_nodes(
&new_model.file_tree_nodes,
&new_model.search_query,
&mut new_model.session,
);
if let Some(display_node) = visible_nodes.get(index)
&& display_node.node.is_directory
{
let node_path = display_node.node.path.clone();
let name = display_node.node.name.clone();
// Find and collapse the node in the tree
let mut found = false;
for root_node in &mut new_model.file_tree_nodes {
if let Some(node) = root_node.find_node_mut(&node_path)
&& node.is_expanded
{
node.is_expanded = false;
new_model.status_message = format!("Collapsed {}", name);
found = true;
break;
}
}
if !found {
new_model.status_message = format!("Could not find directory {}", name);
}
}
(new_model, Cmd::None)
}
Message::ToggleSetting(index) => {
let items = new_model.settings.get_settings_items(&new_model.session);
if let Some(item) = items.get(index) {
let setting_name = new_model.settings.update_setting_by_key(
&mut new_model.session,
item.key,
SettingAction::Toggle,
);
new_model.status_message = format!("Toggled {}", setting_name);
} else {
new_model.status_message = format!("Invalid setting index: {}", index);
}
(new_model, Cmd::None)
}
Message::CycleSetting(index) => {
let items = new_model.settings.get_settings_items(&new_model.session);
if let Some(item) = items.get(index) {
let setting_name = new_model.settings.update_setting_by_key(
&mut new_model.session,
item.key,
SettingAction::Cycle,
);
new_model.status_message = format!("Cycled {}", setting_name);
} else {
new_model.status_message = format!("Invalid setting index: {}", index);
}
(new_model, Cmd::None)
}
Message::RunAnalysis => {
if !new_model.prompt_output.analysis_in_progress {
new_model.prompt_output.analysis_in_progress = true;
new_model.prompt_output.analysis_error = None;
new_model.status_message = "Running analysis...".to_string();
new_model.current_tab = Tab::PromptOutput; // Switch to output tab
let cmd = Cmd::RunAnalysis {
template_content: new_model.template.get_template_content().to_string(),
user_variables: new_model.template.variables.user_variables.clone(),
};
(new_model, cmd)
} else {
new_model.status_message = "Analysis already in progress...".to_string();
(new_model, Cmd::None)
}
}
Message::AnalysisComplete(results) => {
new_model.prompt_output.analysis_in_progress = false;
new_model.prompt_output.generated_prompt = Some(results.generated_prompt);
new_model.prompt_output.token_count = results.token_count;
new_model.prompt_output.file_count = results.file_count;
// Reset output scroll so the new content starts at the top.
new_model.prompt_output.output_scroll = 0;
new_model.statistics.token_map_entries = results.token_map_entries;
let tokens = results.token_count.unwrap_or(0);
new_model.status_message = format!(
"Analysis complete! {} tokens, {} files",
tokens, results.file_count
);
(new_model, Cmd::None)
}
Message::AnalysisError(error) => {
new_model.prompt_output.analysis_in_progress = false;
new_model.prompt_output.analysis_error = Some(error.clone());
new_model.status_message = format!("Analysis failed: {}", error);
(new_model, Cmd::None)
}
Message::CopyToClipboard => {
if let Some(prompt) = &new_model.prompt_output.generated_prompt {
let cmd = Cmd::CopyToClipboard(prompt.clone());
(new_model, cmd)
} else {
new_model.status_message = "No prompt to copy".to_string();
(new_model, Cmd::None)
}
}
Message::SaveToFile(filename) => {
if let Some(prompt) = &new_model.prompt_output.generated_prompt {
let cmd = Cmd::SaveToFile {
filename,
content: prompt.clone(),
};
(new_model, cmd)
} else {
new_model.status_message = "No prompt to save".to_string();
(new_model, Cmd::None)
}
}
Message::ScrollOutput(delta) => {
// Apply delta only; widgets will clamp based on actual viewport.
let new_scroll = if delta < 0 {
new_model
.prompt_output
.output_scroll
.saturating_sub((-delta) as u16)
} else {
new_model
.prompt_output
.output_scroll
.saturating_add(delta as u16)
};
new_model.prompt_output.output_scroll = new_scroll;
(new_model, Cmd::None)
}
Message::CycleStatisticsView(direction) => {
new_model.statistics.view = if direction > 0 {
new_model.statistics.view.next()
} else {
new_model.statistics.view.prev()
};
new_model.statistics.scroll = 0;
new_model.status_message =
format!("Switched to {} view", new_model.statistics.view.as_str());
(new_model, Cmd::None)
}
Message::ScrollStatistics(delta) => {
let new_scroll = if delta < 0 {
new_model.statistics.scroll.saturating_sub((-delta) as u16)
} else {
new_model.statistics.scroll.saturating_add(delta as u16)
};
new_model.statistics.scroll = new_scroll;
(new_model, Cmd::None)
}
Message::SaveTemplate(filename) => {
let content = new_model.template.get_template_content().to_string();
let cmd = Cmd::SaveTemplate {
filename: filename.clone(),
content,
};
new_model.status_message = "Saving template...".to_string();
(new_model, cmd)
}
Message::ReloadTemplate => {
new_model.template.editor = crate::model::template::EditorState::default();
new_model.template.sync_variables_with_template();
new_model.status_message = "Reloaded template".to_string();
(new_model, Cmd::None)
}
Message::LoadTemplate => {
let result = new_model.template.load_selected_template();
match result {
Ok(template_name) => {
new_model.template.sync_variables_with_template();
new_model.status_message = format!("Loaded template: {}", template_name);
}
Err(e) => {
new_model.status_message = format!("Failed to load template: {}", e);
}
}
(new_model, Cmd::None)
}
Message::RefreshTemplates => {
new_model.template.picker.refresh();
new_model.status_message = "Templates refreshed".to_string();
(new_model, Cmd::None)
}
Message::SetTemplateFocus(focus, mode) => {
new_model.template.set_focus(focus);
new_model.template.set_focus_mode(mode);
if mode == crate::model::template::FocusMode::EditingVariable {
new_model
.template
.variables
.move_to_first_missing_variable();
}
new_model.status_message = format!("Template focus: {:?} ({:?})", focus, mode);
(new_model, Cmd::None)
}
Message::SetTemplateFocusMode(mode) => {
new_model.template.set_focus_mode(mode);
new_model.status_message = format!("Template mode: {:?}", mode);
(new_model, Cmd::None)
}
Message::TemplateEditorInput(key) => {
new_model.template.editor.editor.input(key);
new_model.template.editor.sync_content_from_textarea();
new_model.template.editor.validate_template();
new_model.template.sync_variables_with_template();
(new_model, Cmd::None)
}
Message::TemplatePickerMove(delta) => {
if delta > 0 {
new_model.template.picker.move_cursor_down();
} else {
new_model.template.picker.move_cursor_up();
}
(new_model, Cmd::None)
}
Message::VariableStartEditing(var_name) => {
new_model.template.variables.editing_variable = Some(var_name.clone());
new_model.template.variables.show_variable_input = true;
new_model.template.variables.variable_input_content.clear();
new_model.status_message = format!("Editing variable: {}", var_name);
(new_model, Cmd::None)
}
Message::VariableInputChar(c) => {
new_model.template.variables.add_char_to_input(c);
(new_model, Cmd::None)
}
Message::VariableInputBackspace => {
new_model.template.variables.remove_char_from_input();
(new_model, Cmd::None)
}
Message::VariableInputEnter => {
if let Some((var_name, value)) = new_model.template.variables.finish_editing() {
new_model.status_message = format!("Set {} = {}", var_name, value);
new_model.template.sync_variables_with_template();
}
(new_model, Cmd::None)
}
Message::VariableInputCancel => {
new_model.template.variables.cancel_editing();
new_model.status_message = "Cancelled variable editing".to_string();
(new_model, Cmd::None)
}
Message::VariableNavigateUp => {
if new_model.template.variables.cursor > 0 {
new_model.template.variables.cursor -= 1;
}
(new_model, Cmd::None)
}
Message::VariableNavigateDown => {
let variables = new_model.template.get_organized_variables();
if new_model.template.variables.cursor < variables.len().saturating_sub(1) {
new_model.template.variables.cursor += 1;
}
(new_model, Cmd::None)
}
}
}
}
================================================
FILE: crates/code2prompt/src/model/prompt_output.rs
================================================
//! Prompt output state management for the TUI application.
//!
//! This module contains the prompt output state and related functionality
//! for managing generated prompts and analysis results in the TUI.
/// Prompt output state containing all prompt output related data
#[derive(Debug, Default, Clone)]
pub struct PromptOutputState {
pub generated_prompt: Option,
pub token_count: Option,
pub file_count: usize,
pub analysis_in_progress: bool,
pub analysis_error: Option,
pub output_scroll: u16,
}
/// Results from code2prompt analysis
#[derive(Debug, Clone)]
pub struct AnalysisResults {
pub file_count: usize,
pub token_count: Option,
pub generated_prompt: String,
pub token_map_entries: Vec,
}
================================================
FILE: crates/code2prompt/src/model/settings.rs
================================================
//! Settings state management for the TUI application.
//!
//! This module contains the settings state, settings groups, and related
//! functionality for managing configuration options in the TUI.
use code2prompt_core::session::Code2PromptSession;
use code2prompt_core::template::OutputFormat;
use code2prompt_core::tokenizer::TokenFormat;
/// Settings state containing cursor position and related data
#[derive(Default, Debug, Clone)]
pub struct SettingsState {
pub settings_cursor: usize,
}
/// Settings group for organizing settings
#[derive(Debug, Clone)]
pub struct SettingsGroup {
pub name: String,
pub items: Vec,
}
/// Settings item for display and interaction
#[derive(Debug, Clone)]
pub struct SettingsItem {
pub key: SettingKey,
pub name: String,
pub description: String,
pub setting_type: SettingType,
}
#[derive(Debug, Clone)]
pub enum SettingType {
Boolean(bool),
Choice {
options: Vec,
selected: usize,
},
}
#[derive(Debug, Clone)]
pub enum SettingAction {
Toggle,
Cycle,
}
/// Unique identifier for each setting
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SettingKey {
LineNumbers,
AbsolutePaths,
NoCodeblock,
OutputFormat,
TokenFormat,
FullDirectoryTree,
SortMethod,
TokenizerType,
GitDiff,
FollowSymlinks,
HiddenFiles,
NoIgnore,
Deselected,
}
impl SettingsState {
/// Get flattened list of settings for display (uses format_settings_groups)
pub fn get_settings_items(&self, session: &Code2PromptSession) -> Vec {
crate::view::format_settings_groups(session)
.into_iter()
.flat_map(|group| group.items)
.collect()
}
/// Update setting based on SettingKey and action
pub fn update_setting_by_key(
&self,
session: &mut Code2PromptSession,
key: SettingKey,
action: SettingAction,
) -> &'static str {
match (key, action) {
(SettingKey::LineNumbers, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.line_numbers = !session.config.line_numbers;
"Line Numbers"
}
(SettingKey::AbsolutePaths, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.absolute_path = !session.config.absolute_path;
"Absolute Paths"
}
(SettingKey::NoCodeblock, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.no_codeblock = !session.config.no_codeblock;
"No Codeblock"
}
(SettingKey::OutputFormat, SettingAction::Cycle) => {
session.config.output_format = match session.config.output_format {
OutputFormat::Markdown => OutputFormat::Json,
OutputFormat::Json => OutputFormat::Xml,
OutputFormat::Xml => OutputFormat::Markdown,
};
"Output Format"
}
(SettingKey::TokenFormat, SettingAction::Cycle) => {
session.config.token_format = match session.config.token_format {
TokenFormat::Raw => TokenFormat::Format,
TokenFormat::Format => TokenFormat::Raw,
};
"Token Format"
}
(SettingKey::FullDirectoryTree, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.full_directory_tree = !session.config.full_directory_tree;
"Full Directory Tree"
}
(SettingKey::SortMethod, SettingAction::Cycle) => {
session.config.sort_method = Some(match session.config.sort_method {
Some(code2prompt_core::sort::FileSortMethod::NameAsc) => {
code2prompt_core::sort::FileSortMethod::NameDesc
}
Some(code2prompt_core::sort::FileSortMethod::NameDesc) => {
code2prompt_core::sort::FileSortMethod::DateAsc
}
Some(code2prompt_core::sort::FileSortMethod::DateAsc) => {
code2prompt_core::sort::FileSortMethod::DateDesc
}
Some(code2prompt_core::sort::FileSortMethod::DateDesc) | None => {
code2prompt_core::sort::FileSortMethod::NameAsc
}
});
"Sort Method"
}
(SettingKey::TokenizerType, SettingAction::Cycle) => {
session.config.encoding = match session.config.encoding {
code2prompt_core::tokenizer::TokenizerType::Cl100kBase => {
code2prompt_core::tokenizer::TokenizerType::O200kBase
}
code2prompt_core::tokenizer::TokenizerType::O200kBase => {
code2prompt_core::tokenizer::TokenizerType::P50kBase
}
code2prompt_core::tokenizer::TokenizerType::P50kBase => {
code2prompt_core::tokenizer::TokenizerType::P50kEdit
}
code2prompt_core::tokenizer::TokenizerType::P50kEdit => {
code2prompt_core::tokenizer::TokenizerType::R50kBase
}
code2prompt_core::tokenizer::TokenizerType::R50kBase => {
code2prompt_core::tokenizer::TokenizerType::Cl100kBase
}
};
"Tokenizer Type"
}
(SettingKey::GitDiff, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.diff_enabled = !session.config.diff_enabled;
"Git Diff"
}
(SettingKey::FollowSymlinks, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.follow_symlinks = !session.config.follow_symlinks;
"Follow Symlinks"
}
(SettingKey::HiddenFiles, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.hidden = !session.config.hidden;
"Hidden Files"
}
(SettingKey::NoIgnore, SettingAction::Toggle | SettingAction::Cycle) => {
session.config.no_ignore = !session.config.no_ignore;
"No Ignore"
}
(SettingKey::Deselected, SettingAction::Toggle | SettingAction::Cycle) => {
session.set_deselected(!session.config.deselected);
"Deselected by Default"
}
_ => "Unknown Setting",
}
}
}
================================================
FILE: crates/code2prompt/src/model/statistics/mod.rs
================================================
//! Statistics state management for the TUI application.
//!
//! This module contains the statistics state and related functionality,
//! including different statistics views and their management.
pub mod types;
use crate::model::DisplayFileNode;
use crate::utils::format_number;
pub use types::*;
/// Statistics state containing all statistics-related data
#[derive(Debug, Clone)]
pub struct StatisticsState {
pub view: StatisticsView,
pub scroll: u16,
pub token_map_entries: Vec,
}
impl Default for StatisticsState {
fn default() -> Self {
StatisticsState {
view: StatisticsView::Overview,
scroll: 0,
token_map_entries: Vec::new(),
}
}
}
impl StatisticsState {
/// Count selected files using session-based approach
pub fn count_selected_files(
session: &mut code2prompt_core::session::Code2PromptSession,
) -> usize {
session.get_selected_files().unwrap_or_default().len()
}
/// Count total files in the tree nodes
pub fn count_total_files(nodes: &[DisplayFileNode]) -> usize {
fn rec(n: &DisplayFileNode) -> usize {
if !n.is_directory {
1
} else {
n.children.iter().map(rec).sum()
}
}
nodes.iter().map(rec).sum()
}
/// Format number according to token format setting (moved from widget)
pub fn format_number(
num: usize,
token_format: &code2prompt_core::tokenizer::TokenFormat,
) -> String {
format_number(num, token_format)
}
/// Aggregate tokens by file extension (moved from widget - business logic belongs in Model)
pub fn aggregate_by_extension(&self) -> Vec<(String, usize, usize)> {
let mut extension_stats: std::collections::HashMap =
std::collections::HashMap::new();
for entry in &self.token_map_entries {
if !entry.metadata.is_dir {
let extension = entry
.name
.split('.')
.next_back()
.map(|ext| format!(".{}", ext))
.unwrap_or_else(|| "(no extension)".to_string());
let (tokens, count) = extension_stats.entry(extension).or_insert((0, 0));
*tokens += entry.tokens;
*count += 1;
}
}
// Convert to sorted vec (by tokens desc)
let mut ext_vec: Vec<(String, usize, usize)> = extension_stats
.into_iter()
.map(|(ext, (tokens, count))| (ext, tokens, count))
.collect();
ext_vec.sort_by(|a, b| b.1.cmp(&a.1));
ext_vec
}
}
================================================
FILE: crates/code2prompt/src/model/statistics/types.rs
================================================
//! Statistics view types and enums.
//!
//! This module contains the StatisticsView enum and related types
//! for managing different statistics views in the TUI.
/// Different views available in the Statistics tab
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatisticsView {
Overview, // General statistics and summary
TokenMap, // Token distribution by directory/file
Extensions, // Token distribution by file extension
}
impl StatisticsView {
pub fn next(&self) -> Self {
match self {
StatisticsView::Overview => StatisticsView::TokenMap,
StatisticsView::TokenMap => StatisticsView::Extensions,
StatisticsView::Extensions => StatisticsView::Overview,
}
}
pub fn prev(&self) -> Self {
match self {
StatisticsView::Overview => StatisticsView::Extensions,
StatisticsView::TokenMap => StatisticsView::Overview,
StatisticsView::Extensions => StatisticsView::TokenMap,
}
}
pub fn as_str(&self) -> &'static str {
match self {
StatisticsView::Overview => "Overview",
StatisticsView::TokenMap => "Token Map",
StatisticsView::Extensions => "Extensions",
}
}
}
================================================
FILE: crates/code2prompt/src/model/template/editor.rs
================================================
//! Template editor state management.
//!
//! This module contains the state and logic for the template editor component,
//! including TextArea management, validation, and content synchronization.
use regex::Regex;
use std::collections::HashSet;
use tui_textarea::TextArea;
/// State for the template editor component
#[derive(Debug)]
pub struct EditorState {
pub content: String,
pub editor: TextArea<'static>,
pub current_template_name: String,
pub is_valid: bool,
pub validation_message: String,
pub template_variables: Vec, // Variables found in template
}
impl Clone for EditorState {
fn clone(&self) -> Self {
let mut new_editor = TextArea::from(self.editor.lines().iter().map(|s| s.as_str()));
new_editor.move_cursor(tui_textarea::CursorMove::Jump(
self.editor.cursor().0.try_into().unwrap_or(0),
self.editor.cursor().1.try_into().unwrap_or(0),
));
Self {
content: self.content.clone(),
editor: new_editor,
current_template_name: self.current_template_name.clone(),
is_valid: self.is_valid,
validation_message: self.validation_message.clone(),
template_variables: self.template_variables.clone(),
}
}
}
impl Default for EditorState {
fn default() -> Self {
// Load default markdown template from API
let content = if let Some(builtin_template) =
code2prompt_core::builtin_templates::BuiltinTemplates::get_template("default-markdown")
{
builtin_template.content
} else {
"# {{project_name}}\n\n{{#if files}}\n{{#each files}}\n## {{path}}\n\n```{{extension}}\n{{content}}\n```\n\n{{/each}}\n{{/if}}"
};
let editor = TextArea::from(content.lines());
let mut state = Self {
content: content.to_string(),
editor,
current_template_name: "Default (Markdown)".to_string(),
is_valid: true,
validation_message: String::new(),
template_variables: Vec::new(),
};
state.analyze_template_variables();
state
}
}
impl EditorState {
/// Update content from TextArea and re-analyze variables
pub fn sync_content_from_textarea(&mut self) {
self.content = self.editor.lines().join("\n");
self.analyze_template_variables();
}
/// Parse template content to extract all {{variable}} references
pub fn analyze_template_variables(&mut self) {
let re = Regex::new(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap();
let mut found_vars = HashSet::new();
for cap in re.captures_iter(&self.content) {
if let Some(var_name) = cap.get(1) {
found_vars.insert(var_name.as_str().to_string());
}
}
self.template_variables = found_vars.into_iter().collect();
self.template_variables.sort();
}
/// Get all variables found in the template
pub fn get_template_variables(&self) -> &[String] {
&self.template_variables
}
/// Validate template syntax with enhanced Handlebars checking
pub fn validate_template(&mut self) {
// First check for balanced braces
let open_count = self.content.matches("{{").count();
let close_count = self.content.matches("}}").count();
if open_count != close_count {
self.is_valid = false;
self.validation_message = format!(
"Unbalanced braces: {} opening, {} closing",
open_count, close_count
);
return;
}
// Try to compile the template with Handlebars
match self.compile_template() {
Ok(_) => {
self.is_valid = true;
self.validation_message = String::new();
}
Err(e) => {
self.is_valid = false;
self.validation_message = format!("Template syntax error: {}", e);
}
}
}
/// Attempt to compile the template to check for syntax errors
fn compile_template(&self) -> Result<(), String> {
let mut handlebars = handlebars::Handlebars::new();
// Set strict mode to catch undefined variables
handlebars.set_strict_mode(false); // Allow undefined variables for now
match handlebars.register_template_string("test", &self.content) {
Ok(_) => Ok(()),
Err(e) => Err(format!("{}", e)),
}
}
/// Get current template content
pub fn get_content(&self) -> &str {
&self.content
}
}
================================================
FILE: crates/code2prompt/src/model/template/mod.rs
================================================
//! Template state management module.
//!
//! This module coordinates the three template sub-components:
//! - Editor: Template content editing and validation
//! - Variable: Variable management and validation
//! - Picker: Template selection and loading
pub mod editor;
pub mod picker;
pub mod variable;
pub use editor::EditorState;
pub use picker::{ActiveList, PickerState};
pub use variable::{VariableCategory, VariableInfo, VariableState};
/// Which component is currently focused
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TemplateFocus {
Editor,
Variables,
Picker,
}
/// Focus mode determines interaction behavior
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FocusMode {
Normal, // Can switch between panels with e/v/p
EditingTemplate, // Locked to editor, ESC to exit
EditingVariable, // Locked to variables, ESC to exit
}
/// Coordinated template state containing all sub-components
#[derive(Debug, Clone)]
pub struct TemplateState {
pub editor: EditorState,
pub variables: VariableState,
pub picker: PickerState,
pub focus: TemplateFocus,
pub focus_mode: FocusMode,
pub status_message: String,
}
impl Default for TemplateState {
fn default() -> Self {
let mut state = Self {
editor: EditorState::default(),
variables: VariableState::default(),
picker: PickerState::default(),
focus: TemplateFocus::Editor,
focus_mode: FocusMode::Normal,
status_message: String::new(),
};
// Initialize variable state with template variables
state.sync_variables_with_template();
state
}
}
impl TemplateState {
/// Create template state from model (for TUI integration)
pub fn from_model(model: &crate::model::Model) -> Self {
// Create a new state based on the model's template state
model.template.clone()
}
/// Synchronize variables with current template content
pub fn sync_variables_with_template(&mut self) {
let template_vars = self.editor.get_template_variables();
self.variables.update_missing_variables(template_vars);
}
/// Set focus to a specific component
pub fn set_focus(&mut self, focus: TemplateFocus) {
self.focus = focus;
}
/// Get current focus
pub fn get_focus(&self) -> TemplateFocus {
self.focus
}
/// Set focus mode
pub fn set_focus_mode(&mut self, mode: FocusMode) {
self.focus_mode = mode;
}
/// Get current focus mode
pub fn get_focus_mode(&self) -> FocusMode {
self.focus_mode
}
/// Check if currently in an editing mode
pub fn is_in_editing_mode(&self) -> bool {
matches!(
self.focus_mode,
FocusMode::EditingTemplate | FocusMode::EditingVariable
)
}
/// Get organized variables for display
pub fn get_organized_variables(&self) -> Vec {
self.variables
.get_organized_variables(self.editor.get_template_variables())
}
/// Get current template content for analysis
pub fn get_template_content(&self) -> &str {
self.editor.get_content()
}
/// Get status message
pub fn get_status(&self) -> &str {
&self.status_message
}
/// Load the currently selected template from the picker
pub fn load_selected_template(&mut self) -> Result {
let selected_template = self.get_selected_template()?;
// Load template content based on type
let (content, template_name) = if selected_template
.path
.to_string_lossy()
.starts_with("builtin://")
{
// Load built-in template from embedded resources
let path_str = selected_template.path.to_string_lossy();
let template_key = path_str.strip_prefix("builtin://").unwrap_or("");
if let Some(builtin_template) =
code2prompt_core::builtin_templates::BuiltinTemplates::get_template(template_key)
{
(
builtin_template.content.to_string(),
builtin_template.name.to_string(),
)
} else {
return Err(format!("Built-in template '{}' not found", template_key));
}
} else {
// Load template from file
let content = std::fs::read_to_string(&selected_template.path)
.map_err(|e| format!("Failed to read template file: {}", e))?;
(content, selected_template.name.clone())
};
// Update editor with new content
self.editor.content = content.clone();
self.editor.current_template_name = template_name.clone();
// Create new TextArea with the content
self.editor.editor = tui_textarea::TextArea::from(content.lines());
// Sync and validate
self.editor.sync_content_from_textarea();
self.editor.validate_template();
Ok(template_name)
}
/// Get the currently selected template from the picker
fn get_selected_template(&self) -> Result<&picker::TemplateFile, String> {
match self.picker.active_list {
ActiveList::Default => self
.picker
.default_templates
.get(self.picker.default_cursor)
.ok_or_else(|| "No default template selected".to_string()),
ActiveList::Custom => self
.picker
.custom_templates
.get(self.picker.custom_cursor)
.ok_or_else(|| "No custom template selected".to_string()),
}
}
}
================================================
FILE: crates/code2prompt/src/model/template/picker.rs
================================================
//! Template picker state management.
//!
//! This module contains the state and logic for the template picker component,
//! including loading templates from default and custom directories.
use std::path::PathBuf;
/// Represents a template file
#[derive(Debug, Clone)]
pub struct TemplateFile {
pub name: String,
pub path: PathBuf,
}
/// Which list is currently active in the picker
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ActiveList {
Default,
Custom,
}
/// State for the template picker component
#[derive(Debug, Clone)]
pub struct PickerState {
pub default_templates: Vec,
pub custom_templates: Vec,
pub active_list: ActiveList,
pub default_cursor: usize,
pub custom_cursor: usize,
}
impl Default for PickerState {
fn default() -> Self {
let mut state = Self {
default_templates: Vec::new(),
custom_templates: Vec::new(),
active_list: ActiveList::Default,
default_cursor: 0,
custom_cursor: 0,
};
state.load_all_templates();
state
}
}
impl PickerState {
/// Load all templates from default and custom directories
pub fn load_all_templates(&mut self) {
self.load_default_templates();
self.load_custom_templates();
}
/// Load built-in default templates
fn load_default_templates(&mut self) {
self.default_templates.clear();
// Load all built-in templates from the core
let builtin_templates = code2prompt_core::builtin_templates::BuiltinTemplates::get_all();
// Sort templates by name for consistent ordering
let mut template_entries: Vec<_> = builtin_templates.iter().collect();
template_entries.sort_by(|a, b| a.1.name.cmp(b.1.name));
for (key, template) in template_entries {
self.default_templates.push(TemplateFile {
name: template.name.to_string(),
path: PathBuf::from(format!("builtin://{}", key)),
});
}
}
/// Load custom templates from user directory
fn load_custom_templates(&mut self) {
self.custom_templates.clear();
// Load templates from custom directory using utility function
if let Ok(all_templates) = crate::utils::load_all_templates() {
for (name, path) in all_templates {
// All templates from load_all_templates are custom
self.custom_templates.push(TemplateFile {
name,
path: PathBuf::from(path),
});
}
}
}
/// Move cursor up in unified list
pub fn move_cursor_up(&mut self) {
let total_items = self.get_total_selectable_items();
if total_items == 0 {
return;
}
let current_global = self.get_global_template_index();
let new_global = if current_global == 0 {
total_items - 1 // Wrap to bottom
} else {
current_global - 1
};
self.set_cursor_from_global_position(new_global);
}
/// Move cursor down in unified list
pub fn move_cursor_down(&mut self) {
let total_items = self.get_total_selectable_items();
if total_items == 0 {
return;
}
let current_global = self.get_global_template_index();
let new_global = (current_global + 1) % total_items;
self.set_cursor_from_global_position(new_global);
}
/// Refresh templates by reloading from directories
pub fn refresh(&mut self) {
self.load_all_templates();
// Reset cursors if they're out of bounds
if self.default_cursor >= self.default_templates.len() {
self.default_cursor = self.default_templates.len().saturating_sub(1);
}
if self.custom_cursor >= self.custom_templates.len() {
self.custom_cursor = self.custom_templates.len().saturating_sub(1);
}
}
/// Get global cursor position for unified list display (for rendering)
pub fn get_global_cursor_position(&self) -> usize {
let mut position = 0;
// Count default templates section
if !self.default_templates.is_empty() {
position += 1; // Section header
if self.active_list == ActiveList::Default {
position += self.default_cursor;
return position;
}
position += self.default_templates.len();
}
// Count custom templates section
if !self.custom_templates.is_empty() {
if !self.default_templates.is_empty() {
position += 1; // Separator
}
position += 1; // Section header
if self.active_list == ActiveList::Custom {
position += self.custom_cursor;
return position;
}
}
position
}
/// Get global template index (for navigation logic)
fn get_global_template_index(&self) -> usize {
match self.active_list {
ActiveList::Default => self.default_cursor,
ActiveList::Custom => self.default_templates.len() + self.custom_cursor,
}
}
/// Get total number of selectable items (templates only, not headers)
fn get_total_selectable_items(&self) -> usize {
self.default_templates.len() + self.custom_templates.len()
}
/// Set cursor position from global position in unified list
fn set_cursor_from_global_position(&mut self, global_pos: usize) {
let mut template_index = 0;
// Check if position is in default templates
if global_pos < self.default_templates.len() {
self.active_list = ActiveList::Default;
self.default_cursor = global_pos;
return;
}
template_index += self.default_templates.len();
// Check if position is in custom templates
if global_pos < template_index + self.custom_templates.len() {
self.active_list = ActiveList::Custom;
self.custom_cursor = global_pos - template_index;
}
}
}
================================================
FILE: crates/code2prompt/src/model/template/variable.rs
================================================
//! Template variable state management.
//!
//! This module contains the state and logic for managing template variables,
//! including system variables, user-defined variables, and missing variables.
use std::collections::HashMap;
/// Variable categories for display and management
#[derive(Debug, Clone, PartialEq)]
pub enum VariableCategory {
System, // From build_template_data
User, // User-defined
Missing, // In template but not defined
}
/// Information about a template variable
#[derive(Debug, Clone)]
pub struct VariableInfo {
pub name: String,
pub value: Option,
pub category: VariableCategory,
pub description: Option,
}
/// State for the template variable component
#[derive(Debug, Clone)]
pub struct VariableState {
pub system_variables: HashMap, // System variables with descriptions
pub user_variables: HashMap, // User-defined variables
pub missing_variables: Vec, // Variables in template but not defined
pub cursor: usize, // Current cursor position in variable list
pub editing_variable: Option, // Currently editing variable name
pub variable_input_content: String, // Content being typed for variable
pub show_variable_input: bool, // Show variable input dialog
}
impl Default for VariableState {
fn default() -> Self {
Self {
system_variables: Self::get_default_system_variables(),
user_variables: HashMap::new(),
missing_variables: Vec::new(),
cursor: 0,
editing_variable: None,
variable_input_content: String::new(),
show_variable_input: false,
}
}
}
impl VariableState {
/// Get default system variables that are available from build_template_data
fn get_default_system_variables() -> HashMap {
let mut vars = HashMap::new();
// Main template variables from build_template_data()
vars.insert(
"absolute_code_path".to_string(),
"Path to the codebase directory".to_string(),
);
vars.insert(
"source_tree".to_string(),
"Directory tree structure".to_string(),
);
vars.insert(
"files".to_string(),
"Array of file objects with content".to_string(),
);
vars.insert(
"git_diff".to_string(),
"Git diff output (if enabled)".to_string(),
);
vars.insert(
"git_diff_branch".to_string(),
"Git diff between branches".to_string(),
);
vars.insert(
"git_log_branch".to_string(),
"Git log between branches".to_string(),
);
// File object properties (used within {{#each files}} loops)
vars.insert(
"path".to_string(),
"File path (available in {{#each files}} context)".to_string(),
);
vars.insert(
"code".to_string(),
"File content (available in {{#each files}} context)".to_string(),
);
vars.insert(
"extension".to_string(),
"File extension (available in {{#each files}} context)".to_string(),
);
vars.insert(
"token_count".to_string(),
"Token count for file (available in {{#each files}} context)".to_string(),
);
vars.insert(
"metadata".to_string(),
"File metadata (available in {{#each files}} context)".to_string(),
);
vars.insert(
"mod_time".to_string(),
"File modification time (available in {{#each files}} context)".to_string(),
);
vars
}
/// Update missing variables based on template variables
pub fn update_missing_variables(&mut self, template_variables: &[String]) {
self.missing_variables.clear();
for var in template_variables {
if !self.system_variables.contains_key(var) && !self.user_variables.contains_key(var) {
self.missing_variables.push(var.clone());
}
}
self.missing_variables.sort();
}
/// Get all variables organized by category for display
pub fn get_organized_variables(&self, template_variables: &[String]) -> Vec {
let mut variables = Vec::new();
// System variables (only those used in template)
for var in template_variables {
if let Some(desc) = self.system_variables.get(var) {
variables.push(VariableInfo {
name: var.clone(),
value: Some("(system)".to_string()),
category: VariableCategory::System,
description: Some(desc.clone()),
});
}
}
// User variables (only those used in template)
for var in template_variables {
if let Some(value) = self.user_variables.get(var) {
variables.push(VariableInfo {
name: var.clone(),
value: Some(value.clone()),
category: VariableCategory::User,
description: None,
});
}
}
// Missing variables
for var in &self.missing_variables {
variables.push(VariableInfo {
name: var.clone(),
value: None,
category: VariableCategory::Missing,
description: Some("⚠️ Not defined".to_string()),
});
}
variables
}
/// Set a user variable
pub fn set_user_variable(&mut self, key: String, value: String) {
self.user_variables.insert(key, value);
}
/// Check if there are missing variables
pub fn has_missing_variables(&self) -> bool {
!self.missing_variables.is_empty()
}
/// Cancel variable editing
pub fn cancel_editing(&mut self) {
self.editing_variable = None;
self.variable_input_content.clear();
self.show_variable_input = false;
}
/// Finish editing variable and save
pub fn finish_editing(&mut self) -> Option<(String, String)> {
if let Some(var_name) = self.editing_variable.take() {
let value = self.variable_input_content.clone();
self.set_user_variable(var_name.clone(), value.clone());
self.variable_input_content.clear();
self.show_variable_input = false;
Some((var_name, value))
} else {
None
}
}
/// Add character to variable input
pub fn add_char_to_input(&mut self, c: char) {
self.variable_input_content.push(c);
}
/// Remove character from variable input
pub fn remove_char_from_input(&mut self) {
self.variable_input_content.pop();
}
/// Get current variable input content
pub fn get_input_content(&self) -> &str {
&self.variable_input_content
}
/// Check if currently editing a variable
pub fn is_editing(&self) -> bool {
self.show_variable_input
}
/// Get currently editing variable name
pub fn get_editing_variable(&self) -> Option<&String> {
self.editing_variable.as_ref()
}
/// Move cursor to first missing/user-defined variable
pub fn move_to_first_missing_variable(&mut self) {
// This will be called when entering variable editing mode
// For now, just reset cursor to 0, but we could enhance this
// to find the first missing variable in the organized list
self.cursor = 0;
}
}
================================================
FILE: crates/code2prompt/src/token_map.rs
================================================
//! Token map visualization and analysis.
//!
//! This module provides functionality for generating and displaying visual token maps
//! that show how tokens are distributed across files in a codebase. It creates
//! hierarchical tree structures with visual bars and colors, similar to disk usage
//! analyzers but for token consumption.
use code2prompt_core::path::FileEntry;
use lscolors::{Indicator, LsColors};
use serde::Deserialize;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BinaryHeap, HashMap};
use std::path::Path;
use unicode_width::UnicodeWidthStr;
/// Color information for TUI rendering
#[derive(Debug, Clone)]
pub enum TuiColor {
White,
Gray,
Red,
Green,
Blue,
Yellow,
Cyan,
Magenta,
LightRed,
LightGreen,
LightBlue,
LightYellow,
LightCyan,
LightMagenta,
}
/// Formatted line for TUI token map display with separate components
#[derive(Debug, Clone)]
pub struct TuiTokenMapLine {
pub tokens_part: String,
pub prefix_part: String,
pub name_part: String,
pub name_color: TuiColor,
pub bar_part: String,
pub percentage_part: String,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct EntryMetadata {
pub is_dir: bool,
}
#[derive(Debug, Clone)]
struct TreeNode {
tokens: usize,
children: BTreeMap,
path: String,
metadata: Option,
}
impl TreeNode {
fn with_path(path: String) -> Self {
TreeNode {
tokens: 0,
children: BTreeMap::new(),
path,
metadata: None,
}
}
}
// For priority queue ordering
#[derive(Debug, Clone, Eq, PartialEq)]
struct NodePriority {
tokens: usize,
path: String,
depth: usize,
}
impl Ord for NodePriority {
fn cmp(&self, other: &Self) -> Ordering {
// Order by tokens (descending), then by depth (ascending), then by path
self.tokens
.cmp(&other.tokens)
.then_with(|| other.depth.cmp(&self.depth))
.then_with(|| self.path.cmp(&other.path))
}
}
impl PartialOrd for NodePriority {
fn partial_cmp(&self, other: &Self) -> Option {
Some(self.cmp(other))
}
}
/// Generate a hierarchical token map with optional display limits.
///
/// Creates a tree structure showing token distribution across files and directories,
/// with optional limits on the number of entries and minimum percentage thresholds
/// for inclusion in the output.
///
/// # Arguments
///
/// * `files` - Array of file metadata from the code2prompt session
/// * `total_tokens` - Total token count for percentage calculations
/// * `max_lines` - Maximum number of entries to return (None for unlimited)
/// * `min_percent` - Minimum percentage threshold for inclusion (None for no limit)
///
/// # Returns
///
/// * `Vec` - Hierarchical list of token map entries ready for display
pub fn generate_token_map_with_limit(
files: &[FileEntry],
total_tokens: usize,
max_lines: Option,
min_percent: Option,
) -> Vec {
let max_lines = max_lines.unwrap_or(20);
let min_percent = min_percent.unwrap_or(0.1);
let mut root = TreeNode::with_path(String::new());
root.tokens = total_tokens;
// Insert all files into the tree
for file in files {
let path_str = &file.path;
let tokens = file.token_count;
let metadata = EntryMetadata {
is_dir: file.metadata.is_dir,
};
let path = Path::new(path_str);
// Skip the root component if it exists
let components: Vec<_> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
insert_path(&mut root, &components, tokens, String::new(), metadata);
}
// Use priority queue to select most significant entries
let allowed_nodes = select_nodes_to_display(&root, total_tokens, max_lines, min_percent);
// Convert tree to sorted entries for display
let mut entries = Vec::new();
rebuild_filtered_tree(
&root,
String::new(),
&allowed_nodes,
&mut entries,
0,
total_tokens,
true,
);
// Add summary for hidden files if needed
let displayed_tokens: usize = entries
.iter()
.map(|e| {
if !e.metadata.is_dir {
e.tokens
} else {
// For directories, only count their direct file children to avoid double counting
0
}
})
.sum();
let hidden_tokens = calculate_file_tokens(&root) - displayed_tokens;
if hidden_tokens > 0 {
entries.push(TokenMapEntry {
path: "(other files)".to_string(),
name: "(other files)".to_string(),
tokens: hidden_tokens,
percentage: (hidden_tokens as f64 / total_tokens as f64) * 100.0,
depth: 0,
is_last: true,
metadata: EntryMetadata { is_dir: false },
});
}
entries
}
fn calculate_file_tokens(node: &TreeNode) -> usize {
if node.metadata.is_some_and(|m| !m.is_dir) {
node.tokens
} else {
node.children.values().map(calculate_file_tokens).sum()
}
}
fn insert_path(
node: &mut TreeNode,
components: &[&str],
tokens: usize,
parent_path: String,
file_metadata: EntryMetadata,
) {
if components.is_empty() {
return;
}
if components.len() == 1 {
// This is a file
let file_name = components[0].to_string();
let file_path = if parent_path.is_empty() {
file_name.clone()
} else {
format!("{}/{}", parent_path, file_name)
};
let child = node
.children
.entry(file_name)
.or_insert_with(|| TreeNode::with_path(file_path));
child.tokens = tokens;
child.metadata = Some(file_metadata);
} else {
// This is a directory
let dir_name = components[0].to_string();
let dir_path = if parent_path.is_empty() {
dir_name.clone()
} else {
format!("{}/{}", parent_path, dir_name)
};
let child = node
.children
.entry(dir_name)
.or_insert_with(|| TreeNode::with_path(dir_path.clone()));
child.tokens += tokens;
child.metadata = Some(EntryMetadata { is_dir: true });
insert_path(child, &components[1..], tokens, dir_path, file_metadata);
}
}
#[derive(Debug, Clone)]
pub struct TokenMapEntry {
pub path: String,
pub name: String,
pub tokens: usize,
pub percentage: f64,
pub depth: usize,
pub is_last: bool,
pub metadata: EntryMetadata,
}
/// Select nodes to display using priority queue
fn select_nodes_to_display(
root: &TreeNode,
total_tokens: usize,
max_lines: usize,
min_percent: f64,
) -> HashMap {
let mut heap = BinaryHeap::new();
let mut allowed_nodes = HashMap::new();
let min_tokens = (total_tokens as f64 * min_percent / 100.0) as usize;
// Start with root children
for child in root.children.values() {
if child.tokens >= min_tokens {
heap.push(NodePriority {
tokens: child.tokens,
path: child.path.clone(),
depth: 0,
});
}
}
// Process nodes by priority
while allowed_nodes.len() < max_lines.saturating_sub(1) && !heap.is_empty() {
if let Some(node_priority) = heap.pop() {
allowed_nodes.insert(node_priority.path.clone(), node_priority.depth);
// Find the node in the tree and add its children
if let Some(node) = find_node_by_path(root, &node_priority.path) {
for child in node.children.values() {
if child.tokens >= min_tokens && !allowed_nodes.contains_key(&child.path) {
heap.push(NodePriority {
tokens: child.tokens,
path: child.path.clone(),
depth: node_priority.depth + 1,
});
}
}
}
}
}
allowed_nodes
}
/// Find a node by its path
fn find_node_by_path<'a>(root: &'a TreeNode, path: &str) -> Option<&'a TreeNode> {
if path.is_empty() {
return Some(root);
}
let components: Vec<&str> = path.split('/').collect();
let mut current = root;
for component in components {
match current.children.get(component) {
Some(child) => current = child,
None => return None,
}
}
Some(current)
}
/// Rebuild tree with only allowed nodes
fn rebuild_filtered_tree(
node: &TreeNode,
path: String,
allowed_nodes: &HashMap,
entries: &mut Vec,
depth: usize,
total_tokens: usize,
is_last: bool,
) {
// Check if this node should be included
if !path.is_empty() && allowed_nodes.contains_key(&path) {
let percentage = (node.tokens as f64 / total_tokens as f64) * 100.0;
let name = path.split('/').next_back().unwrap_or(&path).to_string();
let metadata = node.metadata.unwrap_or(EntryMetadata { is_dir: true });
entries.push(TokenMapEntry {
path: path.clone(),
name,
tokens: node.tokens,
percentage,
depth,
is_last,
metadata,
});
}
// Process children that are in allowed_nodes
let mut filtered_children: Vec<_> = node
.children
.iter()
.filter(|(_, child)| allowed_nodes.contains_key(&child.path))
.collect();
// Sort by tokens descending
filtered_children.sort_by(|a, b| b.1.tokens.cmp(&a.1.tokens));
let child_count = filtered_children.len();
for (i, (name, child)) in filtered_children.into_iter().enumerate() {
let child_path = if path.is_empty() {
name.clone()
} else {
format!("{}/{}", path, name)
};
let is_last_child = i == child_count - 1;
rebuild_filtered_tree(
child,
child_path,
allowed_nodes,
entries,
depth + 1,
total_tokens,
is_last_child,
);
}
}
fn should_enable_colors() -> bool {
// Check NO_COLOR environment variable (https://no-color.org/)
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
// Check if we're in a terminal
if terminal_size::terminal_size().is_none() {
return false;
}
// On Windows, enable ANSI support
#[cfg(windows)]
{
use log::error;
match ansi_term::enable_ansi_support() {
Ok(_) => true,
Err(_) => {
error!("This version of Windows does not support ANSI colors");
false
}
}
}
#[cfg(not(windows))]
{
true
}
}
/// Display a visual token map with colors and hierarchical tree structure.
///
/// Renders the token map entries as a formatted tree with visual progress bars,
/// colors based on file types, and proper Unicode tree drawing characters.
/// Automatically adapts to terminal width and applies appropriate colors.
///
/// # Arguments
///
/// * `entries` - The token map entries to display
/// * `total_tokens` - Total token count for percentage calculations
pub fn display_token_map(entries: &[TokenMapEntry], total_tokens: usize) {
if entries.is_empty() {
return;
}
// Initialize LsColors from environment
let ls_colors = LsColors::from_env().unwrap_or_default();
let colors_enabled = should_enable_colors();
// Terminal width detection
let terminal_width = terminal_size::terminal_size()
.map(|(terminal_size::Width(w), _)| w as usize)
.unwrap_or(80);
// Calculate max token width for alignment
let max_token_width = entries
.iter()
.map(|e| format_tokens(e.tokens).len())
.max()
.unwrap_or(3)
.max(format_tokens(total_tokens).len())
.max(4);
// Calculate max name length including tree prefix
let max_name_length = entries
.iter()
.map(|e| {
let prefix_width = if e.depth == 0 { 3 } else { (e.depth * 2) + 3 };
prefix_width + UnicodeWidthStr::width(e.name.as_str())
})
.max()
.unwrap_or(20)
.min(terminal_width / 2);
// Calculate bar width
let bar_width = terminal_width
.saturating_sub(max_token_width + 3 + max_name_length + 2 + 2 + 5)
.max(20);
// Initialize parent bars array
let mut parent_bars: Vec = vec![String::new(); 10];
parent_bars[0] = "█".repeat(bar_width);
for (i, entry) in entries.iter().enumerate() {
// Build tree prefix using shared logic
let prefix = build_tree_prefix(entry, entries, i);
// Format tokens
let tokens_str = format_tokens(entry.tokens);
// Generate hierarchical bar
let parent_bar = if entry.depth > 0 {
&parent_bars[entry.depth - 1]
} else {
&parent_bars[0]
};
let bar = generate_hierarchical_bar(bar_width, parent_bar, entry.percentage, entry.depth);
// Update parent bars
if entry.depth < parent_bars.len() {
parent_bars[entry.depth] = bar.clone();
}
// Format percentage
let percentage_str = format!("{:>4.0}%", entry.percentage);
// Calculate padding for name
let prefix_display_width = prefix.chars().count();
let name_padding = max_name_length
.saturating_sub(prefix_display_width + UnicodeWidthStr::width(entry.name.as_str()));
// Create name with padding FIRST
let name_with_padding = format!("{}{}", entry.name, " ".repeat(name_padding));
// THEN apply colors to the name+padding combination
let colored_name_with_padding = if colors_enabled && entry.name != "(other files)" {
// Use our cached metadata to choose the coloring strategy
let ansi_style = if entry.metadata.is_dir {
// For directories, we know the type. No need to hit the filesystem.
ls_colors
.style_for_indicator(Indicator::Directory)
.map(|s| s.to_ansi_term_style())
.unwrap_or_default()
} else {
// For files, rely on extension-based styling (no filesystem stat).
ls_colors
.style_for_path(std::path::Path::new(&entry.path))
.map(lscolors::Style::to_ansi_term_style)
.unwrap_or_default()
};
// Apply style to name WITH padding
format!("{}", ansi_style.paint(name_with_padding))
} else {
name_with_padding
};
eprintln!(
"{:>width$} {}{} │{}│ {}",
tokens_str,
prefix,
colored_name_with_padding,
bar,
percentage_str,
width = max_token_width
);
}
}
/// Build tree prefix for an entry (shared logic for CLI and TUI)
fn build_tree_prefix(entry: &TokenMapEntry, entries: &[TokenMapEntry], index: usize) -> String {
let mut prefix = String::new();
// Add vertical lines for parent levels
for d in 0..entry.depth {
if d < entry.depth - 1 {
// Check if we need a vertical line at this depth
let needs_line = entries
.iter()
.skip(index + 1)
.take_while(|entry| entry.depth > d)
.any(|entry| entry.depth == d + 1);
if needs_line {
prefix.push_str("│ ");
} else {
prefix.push_str(" ");
}
} else if entry.is_last {
prefix.push_str("└─");
} else {
prefix.push_str("├─");
}
}
// Special handling for root
if entry.depth == 0 && index == 0 && entry.name != "(other files)" {
prefix = "┌─".to_string();
}
// Check if has children
let has_children = entries
.get(index + 1)
.map(|next| next.depth > entry.depth)
.unwrap_or(false);
// Add the connecting character
if entry.depth > 0 || entry.name == "(other files)" {
if has_children {
prefix.push('┬');
} else {
prefix.push('─');
}
} else if index == 0 {
prefix.push('┴');
}
prefix.push(' ');
prefix
}
/// Determine TUI color for an entry based on file type and extension
fn determine_tui_color(entry: &TokenMapEntry) -> TuiColor {
if entry.metadata.is_dir {
TuiColor::Cyan
} else {
match entry.name.split('.').next_back().unwrap_or("") {
// Systems / compiled langs
"rs" => TuiColor::Yellow,
"c" | "h" | "cpp" | "cxx" | "hpp" => TuiColor::Blue,
"go" => TuiColor::LightBlue,
"java" | "kt" | "kts" => TuiColor::Red,
"swift" => TuiColor::LightRed,
"zig" => TuiColor::LightYellow,
// Web
"js" | "mjs" | "cjs" => TuiColor::LightGreen,
"ts" | "tsx" | "jsx" => TuiColor::LightCyan,
"html" | "htm" => TuiColor::Magenta,
"css" | "scss" | "less" => TuiColor::LightMagenta,
// Scripting / automation
"py" => TuiColor::LightYellow,
"sh" | "bash" | "zsh" => TuiColor::Gray,
"rb" => TuiColor::LightRed,
"pl" => TuiColor::LightCyan,
"php" => TuiColor::LightMagenta,
"lua" => TuiColor::LightBlue,
// Data / config / markup
"json" | "toml" | "yaml" | "yml" => TuiColor::Magenta,
"xml" => TuiColor::LightGreen,
"csv" => TuiColor::Green,
"ini" => TuiColor::Gray,
// Docs
"md" | "txt" | "rst" | "adoc" => TuiColor::Green,
"pdf" => TuiColor::Red,
// Default
_ => TuiColor::White,
}
}
}
/// Format token map entries for TUI display with adaptive layout.
///
/// Creates formatted lines with tree structure and color information suitable
/// for rendering in a TUI interface using ratatui. This function uses the same
/// adaptive layout logic as the CLI version but returns structured data components
/// instead of printing directly.
///
/// # Arguments
///
/// * `entries` - The token map entries to format
/// * `total_tokens` - Total token count for percentage calculations
/// * `terminal_width` - Width of the terminal/TUI area for adaptive layout
///
/// # Returns
///
/// * `Vec` - Formatted lines ready for TUI rendering
pub fn format_token_map_for_tui(
entries: &[TokenMapEntry],
total_tokens: usize,
terminal_width: usize,
) -> Vec {
if entries.is_empty() {
return Vec::new();
}
// Use the same adaptive layout logic as CLI
let terminal_width = terminal_width.max(80); // Minimum width
// Calculate max token width for alignment (same as CLI)
let max_token_width = entries
.iter()
.map(|e| format_tokens(e.tokens).len())
.max()
.unwrap_or(3)
.max(format_tokens(total_tokens).len())
.max(4);
// Calculate max name length including tree prefix (same as CLI)
let max_name_length = entries
.iter()
.map(|e| {
let prefix_width = if e.depth == 0 { 3 } else { (e.depth * 2) + 3 };
prefix_width + UnicodeWidthStr::width(e.name.as_str())
})
.max()
.unwrap_or(20)
.min(terminal_width / 2);
// Calculate bar width (adjusted for TUI to prevent overflow)
// TUI needs a bit more space than CLI to prevent the percentage column from overflowing
let bar_width = terminal_width
.saturating_sub(max_token_width + 3 + max_name_length + 2 + 2 + 7) // +2 more chars for TUI
.max(15); // Minimum bar width reduced slightly for TUI
// Initialize parent bars array (same as CLI)
let mut parent_bars: Vec = vec![String::new(); 10];
parent_bars[0] = "█".repeat(bar_width);
let mut lines = Vec::new();
for (i, entry) in entries.iter().enumerate() {
// Build tree prefix using shared logic
let prefix = build_tree_prefix(entry, entries, i);
// Format tokens
let tokens_str = format_tokens(entry.tokens);
// Generate hierarchical bar (same as CLI)
let parent_bar = if entry.depth > 0 {
&parent_bars[entry.depth - 1]
} else {
&parent_bars[0]
};
let bar = generate_hierarchical_bar(bar_width, parent_bar, entry.percentage, entry.depth);
// Update parent bars (same as CLI)
if entry.depth < parent_bars.len() {
parent_bars[entry.depth] = bar.clone();
}
// Format percentage
let percentage_str = format!("{:>4.0}%", entry.percentage);
// Calculate padding for name (same as CLI)
let prefix_display_width = prefix.chars().count();
let name_padding = max_name_length
.saturating_sub(prefix_display_width + UnicodeWidthStr::width(entry.name.as_str()));
// Create name with padding
let name_with_padding = format!("{}{}", entry.name, " ".repeat(name_padding));
// Determine color based on entry type and extension
let name_color = determine_tui_color(entry);
// Create structured components for TUI rendering
lines.push(TuiTokenMapLine {
tokens_part: format!("{:>width$}", tokens_str, width = max_token_width),
prefix_part: prefix,
name_part: name_with_padding,
name_color,
bar_part: format!("│{}│", bar),
percentage_part: percentage_str,
});
}
lines
}
// Format token counts with K/M suffixes (dust-style)
fn format_tokens(tokens: usize) -> String {
if tokens >= 1_000_000 {
let millions = (tokens + 500_000) / 1_000_000;
format!("{}M", millions)
} else if tokens >= 1_000 {
let thousands = (tokens + 500) / 1_000;
format!("{}K", thousands)
} else {
format!("{}", tokens)
}
}
// Generate bar with dust-style depth shading
fn generate_hierarchical_bar(
bar_width: usize,
parent_bar: &str,
percentage: f64,
depth: usize,
) -> String {
// Calculate how many characters should be filled for this entry
let filled_chars = ((percentage / 100.0) * bar_width as f64).round() as usize;
let mut result = String::new();
// Depth determines which shade to use for parent's solid blocks
let shade_char = match depth.max(1) {
1 => ' ', // Level 1: parent blocks become spaces
2 => '░', // Level 2: light shade
3 => '▒', // Level 3: medium shade
_ => '▓', // Level 4+: dark shade
};
// Process each character position
let parent_chars: Vec = parent_bar.chars().collect();
for i in 0..bar_width {
if i < filled_chars {
// This is our filled portion - always solid
result.push('█');
} else if i < parent_chars.len() {
// This is parent's portion
let parent_char = parent_chars[i];
if parent_char == '█' {
// Replace parent's solid blocks with our shade
result.push(shade_char);
} else {
// Keep parent's existing shading
result.push(parent_char);
}
} else {
// Beyond parent's bar - empty
result.push(' ');
}
}
result
}
================================================
FILE: crates/code2prompt/src/tui.rs
================================================
//! Terminal User Interface implementation.
//!
//! This module implements the complete TUI for code2prompt using ratatui and crossterm.
//! It provides a tabbed interface with file selection, settings configuration,
//! statistics viewing, and prompt output. The interface supports keyboard navigation,
//! file tree browsing, real-time analysis, and clipboard integration.
use anyhow::Result;
use code2prompt_core::session::Code2PromptSession;
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
prelude::*,
widgets::*,
};
use std::io::{Stdout, stdout};
use tokio::sync::mpsc;
use crate::clipboard::copy_to_clipboard;
use crate::model::{
AnalysisResults, Cmd, FileTreeInputMode, Message, Model, StatisticsView, Tab, TemplateState,
template::{FocusMode, TemplateFocus, VariableCategory},
};
use crate::token_map::generate_token_map_with_limit;
use crate::utils::{save_template_to_custom_dir, save_to_file};
use crate::widgets::{
FileSelectionWidget, OutputWidget, SettingsWidget, StatisticsByExtensionWidget,
StatisticsOverviewWidget, StatisticsTokenMapWidget, TemplateWidget,
};
use crate::utils::build_file_tree_from_session;
pub struct TuiApp {
model: Model,
terminal: Terminal>,
message_tx: mpsc::UnboundedSender,
message_rx: mpsc::UnboundedReceiver,
}
impl TuiApp {
/// Create a new TUI application.
///
/// Initializes the terminal and sets up the application state from the provided session.
/// The initial file tree is requested via a `RefreshFileTree` message in `run()`.
///
/// Returns an error if the terminal cannot be initialized.
pub fn new(session: Code2PromptSession) -> Result {
let terminal = init_terminal()?;
let (message_tx, message_rx) = mpsc::unbounded_channel();
let model = Model::new(session);
Ok(Self {
model,
terminal,
message_tx,
message_rx,
})
}
// ~~~ Optimized Main Loop ~~~
pub async fn run(&mut self) -> Result<()> {
// Initialize file tree
self.handle_message(Message::RefreshFileTree)?;
loop {
// Process all available events with coalescing
let mut messages = Vec::new();
// Drain all available keyboard events
while crossterm::event::poll(std::time::Duration::from_millis(0))? {
if let crossterm::event::Event::Key(key) = crossterm::event::read()?
&& key.kind == crossterm::event::KeyEventKind::Press
{
// Convert to ratatui KeyEvent
let ratatui_key = self.convert_crossterm_key(key);
// Handle the key event
if let Some(message) = self.handle_key_event(ratatui_key) {
if let Some(last_message) = messages.last_mut()
&& self.try_coalesce_messages(last_message, &message)
{
continue; // Message was coalesced
}
messages.push(message);
}
}
}
// Handle all messages
for message in messages {
self.handle_message(message)?;
}
// Handle internal messages (non-blocking)
while let Ok(message) = self.message_rx.try_recv() {
self.handle_message(message)?;
}
// Render the UI
let model = self.model.clone();
self.terminal.draw(|frame| {
TuiApp::render_with_model(&model, frame);
})?;
if self.model.should_quit {
break;
}
// Small sleep to prevent busy waiting
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
Ok(())
}
/// Render the TUI using the provided model and frame.
///
/// This function handles the layout and rendering of all components based on the current state.
/// It divides the terminal into sections for the tab bar, content area, and status bar,
/// and renders the appropriate widgets for the active tab.
///
/// # Arguments
///
/// * `model` - The current application state model
/// * `frame` - The frame to render the UI components onto
///
fn render_with_model(model: &Model, frame: &mut Frame) {
let area = frame.area();
// ~~~ Main layout ~~~
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tab bar
Constraint::Min(0), // Content
Constraint::Length(3), // Status bar
])
.split(area);
// Tab bar
Self::render_tab_bar_static(model, frame, main_layout[0]);
// Current tab content
match model.current_tab {
Tab::FileTree => {
let widget = FileSelectionWidget::new(model);
let mut state = ();
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
Tab::Settings => {
let widget = SettingsWidget::new(model);
let mut state = ();
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
Tab::Statistics => match model.statistics.view {
StatisticsView::Overview => {
let widget = StatisticsOverviewWidget::new(model);
frame.render_widget(widget, main_layout[1]);
}
StatisticsView::TokenMap => {
let widget = StatisticsTokenMapWidget::new(model);
let mut state = ();
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
StatisticsView::Extensions => {
let widget = StatisticsByExtensionWidget::new(model);
let mut state = ();
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
},
Tab::Template => {
let widget = TemplateWidget::new(model);
let mut state = TemplateState::from_model(model);
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
Tab::PromptOutput => {
let widget = OutputWidget::new(model);
let mut state = ();
frame.render_stateful_widget(widget, main_layout[1], &mut state);
}
}
// Status bar
Self::render_status_bar_static(model, frame, main_layout[2]);
}
/// Handle a key event and return an optional message.
///
/// This function processes keyboard input, prioritizing search mode
/// when active. It handles global shortcuts for tab switching and quitting,
/// as well as delegating tab-specific key events to the appropriate handlers.
/// # Arguments
///
/// * `key` - The key event to handle.
///
/// # Returns
///
/// * `Option` - An optional message to be processed by the main loop.
///
fn handle_key_event(&self, key: KeyEvent) -> Option {
// Check if we're in search mode first - this takes priority over global shortcuts
if self.model.file_tree_input_mode == FileTreeInputMode::Search
&& self.model.current_tab == Tab::FileTree
{
return self.handle_file_tree_keys(key);
}
// Check if we're in template editing mode - ESC should exit editing mode, not quit app
if self.model.current_tab == Tab::Template && self.model.template.is_in_editing_mode() {
if key.code == KeyCode::Esc {
return Some(Message::SetTemplateFocusMode(FocusMode::Normal));
}
// In editing modes, delegate to template handler
return self.handle_template_keys(key);
}
// Global shortcuts (only when not in search mode or template editing mode)
match key.code {
KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Some(Message::Quit);
}
KeyCode::Esc => return Some(Message::Quit),
KeyCode::Char('1') => return Some(Message::SwitchTab(Tab::FileTree)),
KeyCode::Char('2') => return Some(Message::SwitchTab(Tab::Settings)),
KeyCode::Char('3') => return Some(Message::SwitchTab(Tab::Statistics)),
KeyCode::Char('4') => return Some(Message::SwitchTab(Tab::Template)),
KeyCode::Char('5') => return Some(Message::SwitchTab(Tab::PromptOutput)),
KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
// Cycle through tabs: Selection -> Settings -> Statistics -> Template -> Output -> Selection
let next_tab = match self.model.current_tab {
Tab::FileTree => Tab::Settings,
Tab::Settings => Tab::Statistics,
Tab::Statistics => Tab::Template,
Tab::Template => Tab::PromptOutput,
Tab::PromptOutput => Tab::FileTree,
};
return Some(Message::SwitchTab(next_tab));
}
KeyCode::BackTab | KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
// Cycle through tabs in reverse: Selection <- Settings <- Statistics <- Template <- Output <- Selection
let prev_tab = match self.model.current_tab {
Tab::FileTree => Tab::PromptOutput,
Tab::Settings => Tab::FileTree,
Tab::Statistics => Tab::Settings,
Tab::Template => Tab::Statistics,
Tab::PromptOutput => Tab::Template,
};
return Some(Message::SwitchTab(prev_tab));
}
_ => {}
}
// Tab-specific shortcuts
match self.model.current_tab {
Tab::FileTree => self.handle_file_tree_keys(key),
Tab::Settings => self.handle_settings_keys(key),
Tab::Statistics => self.handle_statistics_keys(key),
Tab::Template => self.handle_template_keys(key),
Tab::PromptOutput => self.handle_prompt_output_keys(key),
}
}
fn handle_file_tree_keys(&self, key: KeyEvent) -> Option {
// Pure logic in TUI - no direct widget calls (Elm/Redux pattern)
if self.model.file_tree_input_mode == FileTreeInputMode::Search {
match key.code {
KeyCode::Esc => Some(Message::ExitSearchMode),
KeyCode::Enter => {
// Apply search and exit search mode
Some(Message::ExitSearchMode)
}
KeyCode::Backspace => {
let mut query = self.model.search_query.clone();
query.pop();
Some(Message::UpdateSearchQuery(query))
}
KeyCode::Char(c) => {
let mut query = self.model.search_query.clone();
query.push(c);
Some(Message::UpdateSearchQuery(query))
}
_ => None,
}
} else {
// Normal navigation mode
match key.code {
KeyCode::Up => Some(Message::MoveTreeCursor(-1)),
KeyCode::Down => Some(Message::MoveTreeCursor(1)),
KeyCode::PageUp => Some(Message::MoveTreeCursor(-10)),
KeyCode::PageDown => Some(Message::MoveTreeCursor(10)),
KeyCode::Home => Some(Message::MoveTreeCursor(-9999)),
KeyCode::End => Some(Message::MoveTreeCursor(9999)),
KeyCode::Char(' ') => Some(Message::ToggleFileSelection(self.model.tree_cursor)),
KeyCode::Enter => Some(Message::RunAnalysis),
KeyCode::Right => Some(Message::ExpandDirectory(self.model.tree_cursor)),
KeyCode::Left => Some(Message::CollapseDirectory(self.model.tree_cursor)),
KeyCode::Char('/') => Some(Message::EnterSearchMode),
KeyCode::Char('s') | KeyCode::Char('S') => Some(Message::EnterSearchMode),
KeyCode::Char('r') | KeyCode::Char('R') => Some(Message::RefreshFileTree),
_ => None,
}
}
}
fn handle_settings_keys(&self, key: KeyEvent) -> Option {
match key.code {
KeyCode::Up => Some(Message::MoveSettingsCursor(-1)),
KeyCode::Down => Some(Message::MoveSettingsCursor(1)),
KeyCode::Char(' ') => Some(Message::ToggleSetting(self.model.settings.settings_cursor)),
KeyCode::Left | KeyCode::Right => {
Some(Message::CycleSetting(self.model.settings.settings_cursor))
}
KeyCode::Enter => Some(Message::RunAnalysis),
_ => None,
}
}
fn handle_statistics_keys(&self, key: KeyEvent) -> Option {
match key.code {
KeyCode::Enter => Some(Message::RunAnalysis),
KeyCode::Left => Some(Message::CycleStatisticsView(-1)), // Previous view
KeyCode::Right => Some(Message::CycleStatisticsView(1)), // Next view
KeyCode::Up => Some(Message::ScrollStatistics(-1)),
KeyCode::Down => Some(Message::ScrollStatistics(1)),
KeyCode::PageUp => Some(Message::ScrollStatistics(-5)),
KeyCode::PageDown => Some(Message::ScrollStatistics(5)),
KeyCode::Home => Some(Message::ScrollStatistics(-9999)),
KeyCode::End => Some(Message::ScrollStatistics(9999)),
_ => None,
}
}
fn handle_template_keys(&self, key: KeyEvent) -> Option {
let is_in_editing_mode = self.model.template.is_in_editing_mode();
let current_focus = self.model.template.get_focus();
// Handle ESC key to exit editing modes
if key.code == KeyCode::Esc && is_in_editing_mode {
return Some(Message::SetTemplateFocusMode(FocusMode::Normal));
}
if is_in_editing_mode {
match current_focus {
TemplateFocus::Editor => {
return Some(Message::TemplateEditorInput(key));
}
TemplateFocus::Variables => {
if self.model.template.variables.is_editing() {
// Currently editing a variable value
match key.code {
KeyCode::Char(c) => return Some(Message::VariableInputChar(c)),
KeyCode::Backspace => return Some(Message::VariableInputBackspace),
KeyCode::Enter => return Some(Message::VariableInputEnter),
KeyCode::Esc => return Some(Message::VariableInputCancel),
_ => return None,
}
} else {
// Navigating variables list
match key.code {
KeyCode::Up => return Some(Message::VariableNavigateUp),
KeyCode::Down => return Some(Message::VariableNavigateDown),
KeyCode::Enter | KeyCode::Char(' ') => {
// Start editing the current variable
let variables = self.model.template.get_organized_variables();
if let Some(var) =
variables.get(self.model.template.variables.cursor)
&& var.category == VariableCategory::Missing
{
return Some(Message::VariableStartEditing(var.name.clone()));
}
return None;
}
_ => return None,
}
}
}
_ => {}
}
}
// Normal mode: Handle global shortcuts and focus switching
match key.code {
KeyCode::Char('e') | KeyCode::Char('E') => {
return Some(Message::SetTemplateFocus(
TemplateFocus::Editor,
FocusMode::EditingTemplate,
));
}
KeyCode::Char('v') | KeyCode::Char('V') => {
return Some(Message::SetTemplateFocus(
TemplateFocus::Variables,
FocusMode::EditingVariable,
));
}
KeyCode::Char('p') | KeyCode::Char('P') => {
return Some(Message::SetTemplateFocus(
TemplateFocus::Picker,
FocusMode::Normal,
));
}
KeyCode::Char('s') | KeyCode::Char('S') => {
// Save template with timestamp
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("custom_template_{}", timestamp);
return Some(Message::SaveTemplate(filename));
}
KeyCode::Char('r') | KeyCode::Char('R') => {
// Reload default template
return Some(Message::ReloadTemplate);
}
KeyCode::Enter => {
// Run analysis
return Some(Message::RunAnalysis);
}
_ => {}
}
// Handle input for focused component in normal mode
if current_focus == TemplateFocus::Picker {
match key.code {
KeyCode::Up => return Some(Message::TemplatePickerMove(-1)),
KeyCode::Down => return Some(Message::TemplatePickerMove(1)),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Char('L') | KeyCode::Char(' ') => {
return Some(Message::LoadTemplate);
}
KeyCode::Char('r') | KeyCode::Char('R') => {
return Some(Message::RefreshTemplates);
}
_ => {}
}
}
None
}
fn handle_prompt_output_keys(&self, key: KeyEvent) -> Option {
match key.code {
KeyCode::Up => Some(Message::ScrollOutput(-1)),
KeyCode::Down => Some(Message::ScrollOutput(1)),
KeyCode::PageUp => Some(Message::ScrollOutput(-10)),
KeyCode::PageDown => Some(Message::ScrollOutput(10)),
KeyCode::Home => Some(Message::ScrollOutput(-9999)),
KeyCode::End => Some(Message::ScrollOutput(9999)),
KeyCode::Char('c') | KeyCode::Char('C') => Some(Message::CopyToClipboard),
KeyCode::Char('s') | KeyCode::Char('S') => {
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("prompt_{}.md", timestamp);
Some(Message::SaveToFile(filename))
}
KeyCode::Enter => Some(Message::RunAnalysis),
_ => None,
}
}
/// Handle a message using the Elm/Redux pattern.
/// This uses the pure Model::update() function and executes any side effects.
fn handle_message(&mut self, message: Message) -> Result<()> {
let (new_model, cmd) = self.model.update(message);
self.model = new_model;
// Execute any side effects
self.execute_cmd(cmd)?;
Ok(())
}
/// Execute a command (side effect) from the Model::update() function.
/// This is where all the impure operations happen.
fn execute_cmd(&mut self, cmd: Cmd) -> Result<()> {
match cmd {
Cmd::None => {
// No side effect
}
Cmd::RefreshFileTree => {
// Always use session-based tree building for proper pattern initialization
match build_file_tree_from_session(&mut self.model.session) {
Ok(tree) => {
self.model.file_tree_nodes = tree;
self.model.status_message =
"File tree loaded with patterns applied and files auto-expanded"
.to_string();
}
Err(e) => {
self.model.status_message = format!("Error loading files: {}", e);
}
}
}
Cmd::RunAnalysis {
template_content,
user_variables,
} => {
// Use the current session state (with all user selections)
let mut session = self.model.session.clone();
let tx = self.message_tx.clone();
tokio::spawn(async move {
// Set custom template content
session.config.template_str = template_content;
session.config.template_name = "Custom Template".to_string();
// Transfer user variables from TUI to session config
session.config.user_variables = user_variables;
match session.generate_prompt() {
Ok(rendered) => {
// Convert to AnalysisResults format expected by TUI
let token_map_entries = if rendered.token_count > 0 {
if let Some(files) = session.data.files.as_ref() {
generate_token_map_with_limit(
files,
rendered.token_count,
Some(50),
Some(0.5),
)
} else {
Vec::new()
}
} else {
Vec::new()
};
let result = AnalysisResults {
file_count: rendered.files.len(),
token_count: Some(rendered.token_count),
generated_prompt: rendered.prompt,
token_map_entries,
};
let _ = tx.send(Message::AnalysisComplete(result));
}
Err(e) => {
let _ = tx.send(Message::AnalysisError(e.to_string()));
}
}
});
}
Cmd::CopyToClipboard(content) => match copy_to_clipboard(&content) {
Ok(_) => {
self.model.status_message = "Copied to clipboard!".to_string();
}
Err(e) => {
self.model.status_message = format!("Copy failed: {}", e);
}
},
Cmd::SaveToFile { filename, content } => {
match save_to_file(std::path::Path::new(&filename), &content) {
Ok(_) => {
self.model.status_message = format!("Saved to {}", filename);
}
Err(e) => {
self.model.status_message = format!("Save failed: {}", e);
}
}
}
Cmd::SaveTemplate { filename, content } => {
match save_template_to_custom_dir(std::path::Path::new(&filename), &content) {
Ok(_) => {
self.model.status_message = format!("Template saved as {}", filename);
// Refresh templates to show the new one
self.model.template.picker.refresh();
}
Err(e) => {
self.model.status_message = format!("Template save failed: {}", e);
}
}
}
}
Ok(())
}
fn render_tab_bar_static(model: &Model, frame: &mut Frame, area: Rect) {
let tabs = vec![
"1. Selection",
"2. Settings",
"3. Statistics",
"4. Template",
"5. Output",
];
let selected = match model.current_tab {
Tab::FileTree => 0,
Tab::Settings => 1,
Tab::Statistics => 2,
Tab::Template => 3,
Tab::PromptOutput => 4,
};
let tabs_widget = Tabs::new(tabs)
.block(
Block::default()
.borders(Borders::ALL)
.title("Code2Prompt TUI"),
)
.select(selected)
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(tabs_widget, area);
}
fn render_status_bar_static(model: &Model, frame: &mut Frame, area: Rect) {
let status_text = if !model.status_message.is_empty() {
model.status_message.clone()
} else {
"Tab/Shift+Tab: Switch tabs | 1/2/3/4: Direct tab | Enter: Run Analysis | Esc/Ctrl+Q: Quit".to_string()
};
let status_widget = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Cyan));
frame.render_widget(status_widget, area);
}
/// Convert crossterm KeyEvent to ratatui KeyEvent
fn convert_crossterm_key(&self, key: crossterm::event::KeyEvent) -> KeyEvent {
use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
KeyEvent {
code: match key.code {
crossterm::event::KeyCode::Backspace => KeyCode::Backspace,
crossterm::event::KeyCode::Enter => KeyCode::Enter,
crossterm::event::KeyCode::Left => KeyCode::Left,
crossterm::event::KeyCode::Right => KeyCode::Right,
crossterm::event::KeyCode::Up => KeyCode::Up,
crossterm::event::KeyCode::Down => KeyCode::Down,
crossterm::event::KeyCode::Home => KeyCode::Home,
crossterm::event::KeyCode::End => KeyCode::End,
crossterm::event::KeyCode::PageUp => KeyCode::PageUp,
crossterm::event::KeyCode::PageDown => KeyCode::PageDown,
crossterm::event::KeyCode::Tab => KeyCode::Tab,
crossterm::event::KeyCode::BackTab => KeyCode::BackTab,
crossterm::event::KeyCode::Delete => KeyCode::Delete,
crossterm::event::KeyCode::Insert => KeyCode::Insert,
crossterm::event::KeyCode::F(n) => KeyCode::F(n),
crossterm::event::KeyCode::Char(c) => KeyCode::Char(c),
crossterm::event::KeyCode::Null => KeyCode::Null,
crossterm::event::KeyCode::Esc => KeyCode::Esc,
_ => KeyCode::Null, // Simplified for other key codes
},
modifiers: KeyModifiers::from_bits_truncate(key.modifiers.bits()),
kind: match key.kind {
crossterm::event::KeyEventKind::Press => KeyEventKind::Press,
crossterm::event::KeyEventKind::Repeat => KeyEventKind::Repeat,
crossterm::event::KeyEventKind::Release => KeyEventKind::Release,
},
state: KeyEventState::from_bits_truncate(key.state.bits()),
}
}
/// Try to coalesce two messages if they are similar (e.g., scroll events)
fn try_coalesce_messages(&self, last_message: &mut Message, new_message: &Message) -> bool {
match (last_message, new_message) {
(Message::MoveTreeCursor(delta1), Message::MoveTreeCursor(delta2)) => {
*delta1 += delta2;
true
}
(Message::MoveSettingsCursor(delta1), Message::MoveSettingsCursor(delta2)) => {
*delta1 += delta2;
true
}
(Message::ScrollStatistics(delta1), Message::ScrollStatistics(delta2)) => {
*delta1 += delta2;
true
}
(Message::ScrollOutput(delta1), Message::ScrollOutput(delta2)) => {
*delta1 += delta2;
true
}
(Message::TemplatePickerMove(delta1), Message::TemplatePickerMove(delta2)) => {
*delta1 += delta2;
true
}
_ => false, // Cannot coalesce these messages
}
}
}
/// Run the Terminal User Interface.
///
/// This is the main entry point for the TUI mode. It parses command-line arguments,
/// initializes the TUI application, and runs the main event loop until the user exits.
///
/// # Returns
///
/// * `Result<()>` - Ok on successful exit, Err if initialization or runtime errors occur
///
/// # Errors
///
/// Returns an error if the TUI cannot be initialized or if runtime errors occur during execution.
pub async fn run_tui(session: Code2PromptSession) -> Result<()> {
let mut app = TuiApp::new(session)?;
let result = app.run().await;
// Clean up terminal
restore_terminal()?;
result
}
fn init_terminal() -> Result>> {
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend).map_err(Into::into)
}
fn restore_terminal() -> Result<()> {
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
Ok(())
}
================================================
FILE: crates/code2prompt/src/utils.rs
================================================
//! Utility functions for the TUI application.
//!
//! This module contains helper functions for building file trees,
//! managing file operations, and other utility functions used throughout the TUI.
use crate::model::DisplayFileNode;
use anyhow::Result;
use code2prompt_core::session::Code2PromptSession;
use regex::Regex;
use std::path::Path;
/// Build hierarchical file tree from session using traverse_directory with SelectionEngine
pub fn build_file_tree_from_session(
session: &mut Code2PromptSession,
) -> Result> {
let mut root_nodes = Vec::new();
// Build root level nodes using ignore crate to respect gitignore
use ignore::WalkBuilder;
let walker = WalkBuilder::new(&session.config.path)
.max_depth(Some(1))
.git_ignore(!session.config.no_ignore) // Respect the no_ignore flag
.hidden(!session.config.hidden) // Also respect the hidden flag for consistency
.build();
for entry in walker {
let entry = entry?;
let path = entry.path();
if path == session.config.path {
continue; // Skip root directory itself
}
let mut node = DisplayFileNode::new(path.to_path_buf(), 0);
// Auto-expand recursively if directory contains selected files
if node.is_directory {
auto_expand_recursively(&mut node, session);
}
root_nodes.push(node);
}
// Sort root nodes: directories first, then alphabetically
root_nodes.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
Ok(root_nodes)
}
/// Recursively auto-expand directories that contain selected files
fn auto_expand_recursively(node: &mut DisplayFileNode, session: &mut Code2PromptSession) {
if !node.is_directory {
return;
}
if directory_contains_selected_files(&node.path, session) {
node.is_expanded = true;
// Load children
if let Err(e) = node.load_children(session) {
eprintln!("Warning: Failed to load children for {}: {}", node.name, e);
return;
}
// Recursively auto-expand children
for child in &mut node.children {
if child.is_directory {
auto_expand_recursively(child, session);
}
}
}
}
/// Check if a directory contains any selected files (helper function)
pub(crate) fn directory_contains_selected_files(
dir_path: &Path,
session: &mut Code2PromptSession,
) -> bool {
if let Ok(entries) = std::fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
let relative_path = if let Ok(rel) = path.strip_prefix(&session.config.path) {
rel
} else {
continue;
};
if session.is_file_selected(relative_path) {
return true;
}
// Recursively check subdirectories
if path.is_dir() && directory_contains_selected_files(&path, session) {
return true;
}
}
}
false
}
/// Get visible nodes for display (flattened tree with search filtering)
pub fn get_visible_nodes(
nodes: &[DisplayFileNode],
search_query: &str,
session: &mut Code2PromptSession,
) -> Vec {
let mut visible = Vec::new();
let search_active = !search_query.is_empty();
let matcher = build_query_matcher(search_query);
collect_visible_nodes_recursive(nodes, &matcher, session, &mut visible, search_active);
visible
}
/// Simple matcher that supports case-insensitive substring and '*'/'?' wildcards.
enum QueryMatcher {
Substr(String),
Regex(Regex),
}
fn build_query_matcher(raw: &str) -> QueryMatcher {
// Trim incidental whitespace for more predictable matches.
let raw = raw.trim();
let has_wildcards = raw.contains('*') || raw.contains('?');
if has_wildcards {
// Escape regex meta, then re-introduce wildcards
let mut pat = regex::escape(raw);
pat = pat.replace(r"\*", ".*").replace(r"\?", ".");
let anchored = format!("(?i)^{}$", pat); // (?i) = case-insensitive
QueryMatcher::Regex(Regex::new(&anchored).unwrap_or_else(|_| Regex::new(".*").unwrap()))
} else {
QueryMatcher::Substr(raw.to_lowercase())
}
}
fn matches(m: &QueryMatcher, text: &str) -> bool {
match m {
QueryMatcher::Substr(needle) => text.to_lowercase().contains(needle),
QueryMatcher::Regex(re) => re.is_match(text),
}
}
/// Node with selection state for display
#[derive(Debug, Clone)]
pub struct DisplayNodeWithSelection {
pub node: DisplayFileNode,
pub is_selected: bool,
}
/// Recursively collect visible nodes
fn collect_visible_nodes_recursive(
nodes: &[DisplayFileNode],
matcher: &QueryMatcher,
session: &mut Code2PromptSession,
visible: &mut Vec,
search_active: bool,
) {
for node in nodes {
// Case-insensitive match on name or full path (with optional wildcards)
let matches_current = if matches!(matcher, QueryMatcher::Substr(s) if s.is_empty()) {
true
} else {
matches(matcher, &node.name) || matches(matcher, &node.path.to_string_lossy())
};
if search_active {
// In search mode, traverse into directories regardless of expansion
let mut child_results: Vec = Vec::new();
if node.is_directory {
let children = get_children_for_search(node, session);
collect_visible_nodes_recursive(
&children,
matcher,
session,
&mut child_results,
true,
);
}
let include_self = matches_current || !child_results.is_empty();
if include_self {
let relative_path = if let Ok(rel) = node.path.strip_prefix(&session.config.path) {
rel
} else {
&node.path
};
let is_selected = session.is_file_selected(relative_path);
// Show directories as expanded in search results for better context
let mut node_clone = node.clone();
if node_clone.is_directory {
node_clone.is_expanded = true;
}
visible.push(DisplayNodeWithSelection {
node: node_clone,
is_selected,
});
visible.extend(child_results);
}
} else {
// Normal mode: only include node if it matches (empty query matches all)
if matches_current {
let relative_path = if let Ok(rel) = node.path.strip_prefix(&session.config.path) {
rel
} else {
&node.path
};
let is_selected = session.is_file_selected(relative_path);
visible.push(DisplayNodeWithSelection {
node: node.clone(),
is_selected,
});
// Only descend if the directory is expanded
if node.is_directory && node.is_expanded {
collect_visible_nodes_recursive(
&node.children,
matcher,
session,
visible,
false,
);
}
}
}
}
}
/// Save content to a file
pub fn save_to_file(path: &Path, content: &str) -> Result<()> {
std::fs::write(path, content)?;
Ok(())
}
/// Format a number with thousand separators according to TokenFormat
///
/// - TokenFormat::Raw: returns the number as-is (e.g., "1234567")
/// - TokenFormat::Format: adds separators every 3 digits (e.g., "1,234,567")
///
/// # Arguments
/// * `num` - The number to format
/// * `format` - The token format setting
///
/// # Returns
/// Formatted string representation of the number
pub fn format_number(num: usize, format: &code2prompt_core::tokenizer::TokenFormat) -> String {
use code2prompt_core::tokenizer::TokenFormat;
match format {
TokenFormat::Raw => num.to_string(),
TokenFormat::Format => {
let s = num.to_string();
let chars: Vec = s.chars().collect();
let mut result = String::new();
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
}
}
/// Load children for search mode without mutating the original tree
fn get_children_for_search(
node: &DisplayFileNode,
session: &mut Code2PromptSession,
) -> Vec {
if !node.is_directory {
return Vec::new();
}
if node.children_loaded {
return node.children.clone();
}
// Load children on the fly without mutating the original tree
let mut children: Vec = Vec::new();
// Use ignore crate to respect gitignore
use ignore::WalkBuilder;
let walker = WalkBuilder::new(&node.path)
.max_depth(Some(1))
.git_ignore(!session.config.no_ignore) // Respect the no_ignore flag
.hidden(!session.config.hidden) // Also respect the hidden flag for consistency
.build();
for entry in walker.flatten() {
let path = entry.path();
if path == node.path {
continue;
}
let mut child = DisplayFileNode::new(path.to_path_buf(), node.level + 1);
// Auto-expand if contains selected files
if child.is_directory && directory_contains_selected_files(&child.path, session) {
child.is_expanded = true;
}
children.push(child);
}
// Sort children: directories first, then alphabetically
children.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
children
}
/// Save template to custom directory
pub fn save_template_to_custom_dir(filename: &Path, content: &str) -> Result<()> {
let templates_dir = if let Some(cfg) = dirs::config_dir() {
cfg.join("code2prompt").join("templates")
} else {
// Fallback to current directory if config_dir not available
std::env::current_dir()?.join("templates")
};
std::fs::create_dir_all(&templates_dir)?;
let full_path = templates_dir.join(filename);
std::fs::write(full_path, content)?;
Ok(())
}
/// Find custom templates and return (display_name, absolute_path).
pub fn load_all_templates() -> Result> {
let mut out = Vec::new();
// Candidate roots
let mut roots = Vec::new();
roots.push(std::env::current_dir()?.join("templates"));
if let Some(cfg) = dirs::config_dir() {
roots.push(cfg.join("code2prompt").join("templates"));
}
// Accept common template extensions
let is_template = |p: &Path| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("hbs") | Some("handlebars") | Some("md") | Some("tmpl")
)
};
for root in roots {
if !root.exists() {
continue;
}
for entry in walkdir::WalkDir::new(&root).min_depth(1).max_depth(2) {
let entry = entry?;
let p = entry.path();
if p.is_file() && is_template(p) {
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("template")
.to_string();
out.push((
name,
p.canonicalize()
.unwrap_or_else(|_| p.to_path_buf())
.to_string_lossy()
.into(),
));
}
}
}
// De-duplicate (same path could appear twice)
// Let the compiler infer tuple types for the sort closure.
out.sort_by(|a: &(String, String), b: &(String, String)| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
out.dedup_by(|a, b| a.1 == b.1);
Ok(out)
}
/// Ensure a path exists in the file tree by creating missing intermediate nodes
pub fn ensure_path_exists_in_tree(
root_nodes: &mut Vec,
target_path: &Path,
session: &mut Code2PromptSession,
) -> Result<()> {
let root_path = &session.config.path;
// Get relative path components
let relative_path = if let Ok(rel) = target_path.strip_prefix(root_path) {
rel
} else {
return Ok(()); // Path is not under root, nothing to do
};
let components: Vec<_> = relative_path.components().collect();
if components.is_empty() {
return Ok(());
}
// Build path incrementally
let mut current_path = root_path.to_path_buf();
let mut current_nodes = root_nodes;
for (level, component) in components.into_iter().enumerate() {
current_path.push(component);
// Find or create node at this level
let node_name = component.as_os_str().to_string_lossy().to_string();
// Look for existing node
let existing_index = current_nodes.iter().position(|n| n.name == node_name);
if let Some(index) = existing_index {
// Node exists, ensure it's loaded if it's a directory
let node = &mut current_nodes[index];
if node.is_directory && !node.children_loaded {
let _ = node.load_children(session);
}
current_nodes = &mut current_nodes[index].children;
} else {
// Node doesn't exist, create it
let mut new_node = DisplayFileNode::new(current_path.clone(), level);
if new_node.is_directory {
let _ = new_node.load_children(session);
}
current_nodes.push(new_node);
// Sort to maintain order
current_nodes.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
// Find the newly inserted node
let new_index = current_nodes
.iter()
.position(|n| n.name == node_name)
.unwrap();
current_nodes = &mut current_nodes[new_index].children;
}
}
Ok(())
}
================================================
FILE: crates/code2prompt/src/view/formatters.rs
================================================
//! Formatting functions for display purposes.
//!
//! This module contains pure functions that format data for display in the TUI.
//! These functions were previously scattered in Model and widgets.
use code2prompt_core::sort::FileSortMethod;
use code2prompt_core::template::OutputFormat;
use code2prompt_core::tokenizer::TokenFormat;
use code2prompt_core::{session::Code2PromptSession, tokenizer::TokenizerType};
use crate::model::{SettingKey, SettingType, SettingsGroup, SettingsItem};
/// Format settings groups for display
pub fn format_settings_groups(session: &Code2PromptSession) -> Vec {
vec![
SettingsGroup {
name: "Output Format".to_string(),
items: vec![
SettingsItem {
key: SettingKey::LineNumbers,
name: "Line Numbers".to_string(),
description: "Show line numbers in output".to_string(),
setting_type: SettingType::Boolean(session.config.line_numbers),
},
SettingsItem {
key: SettingKey::AbsolutePaths,
name: "Absolute Paths".to_string(),
description: "Use absolute instead of relative paths".to_string(),
setting_type: SettingType::Boolean(session.config.absolute_path),
},
SettingsItem {
key: SettingKey::NoCodeblock,
name: "No Codeblock".to_string(),
description: "Don't wrap code in markdown blocks".to_string(),
setting_type: SettingType::Boolean(session.config.no_codeblock),
},
SettingsItem {
key: SettingKey::OutputFormat,
name: "Output Format".to_string(),
description: "Format for generated output".to_string(),
setting_type: SettingType::Choice {
options: vec![
"Markdown".to_string(),
"JSON".to_string(),
"XML".to_string(),
],
selected: match session.config.output_format {
OutputFormat::Markdown => 0,
OutputFormat::Json => 1,
OutputFormat::Xml => 2,
},
},
},
SettingsItem {
key: SettingKey::TokenFormat,
name: "Token Format".to_string(),
description: "How to display token counts".to_string(),
setting_type: SettingType::Choice {
options: vec![
TokenFormat::Raw.to_string(),
TokenFormat::Format.to_string(),
],
selected: match session.config.token_format {
TokenFormat::Raw => 0,
TokenFormat::Format => 1,
},
},
},
SettingsItem {
key: SettingKey::FullDirectoryTree,
name: "Full Directory Tree".to_string(),
description: "Show complete directory structure".to_string(),
setting_type: SettingType::Boolean(session.config.full_directory_tree),
},
],
},
SettingsGroup {
name: "Sorting & Organization".to_string(),
items: vec![SettingsItem {
key: SettingKey::SortMethod,
name: "Sort Method".to_string(),
description: "How to sort files in output".to_string(),
setting_type: SettingType::Choice {
options: vec![
FileSortMethod::NameAsc.to_string(),
FileSortMethod::NameDesc.to_string(),
FileSortMethod::DateAsc.to_string(),
FileSortMethod::DateDesc.to_string(),
],
selected: match session.config.sort_method {
Some(FileSortMethod::NameAsc) => 0,
Some(FileSortMethod::NameDesc) => 1,
Some(FileSortMethod::DateAsc) => 2,
Some(FileSortMethod::DateDesc) => 3,
None => 0,
},
},
}],
},
SettingsGroup {
name: "Tokenizer & Encoding".to_string(),
items: vec![SettingsItem {
key: SettingKey::TokenizerType,
name: "Tokenizer Type".to_string(),
description: "Encoding method for token counting".to_string(),
setting_type: SettingType::Choice {
options: vec![
TokenizerType::Cl100kBase.to_string(),
TokenizerType::O200kBase.to_string(),
TokenizerType::P50kBase.to_string(),
TokenizerType::P50kEdit.to_string(),
TokenizerType::R50kBase.to_string(),
],
selected: match session.config.encoding {
TokenizerType::Cl100kBase => 0,
TokenizerType::O200kBase => 1,
TokenizerType::P50kBase => 2,
TokenizerType::P50kEdit => 3,
TokenizerType::R50kBase => 4,
},
},
}],
},
SettingsGroup {
name: "Git Integration".to_string(),
items: vec![SettingsItem {
key: SettingKey::GitDiff,
name: "Git Diff".to_string(),
description: "Include git diff in output".to_string(),
setting_type: SettingType::Boolean(session.config.diff_enabled),
}],
},
SettingsGroup {
name: "File Selection".to_string(),
items: vec![
SettingsItem {
key: SettingKey::FollowSymlinks,
name: "Follow Symlinks".to_string(),
description: "Follow symbolic links".to_string(),
setting_type: SettingType::Boolean(session.config.follow_symlinks),
},
SettingsItem {
key: SettingKey::HiddenFiles,
name: "Hidden Files".to_string(),
description: "Include hidden files and directories".to_string(),
setting_type: SettingType::Boolean(session.config.hidden),
},
SettingsItem {
key: SettingKey::NoIgnore,
name: "No Ignore".to_string(),
description: "Ignore .gitignore rules".to_string(),
setting_type: SettingType::Boolean(session.config.no_ignore),
},
SettingsItem {
key: SettingKey::Deselected,
name: "Deselected by Default".to_string(),
description: "Start with all files deselected".to_string(),
setting_type: SettingType::Boolean(session.config.deselected),
},
],
},
]
}
================================================
FILE: crates/code2prompt/src/view/mod.rs
================================================
//! View layer for the TUI application.
//!
//! This module contains all the formatting and display logic that was previously
//! mixed into the Model and widgets. It provides pure functions that take data
//! and return formatted strings or display structures.
pub mod formatters;
pub use formatters::*;
================================================
FILE: crates/code2prompt/src/widgets/file_selection.rs
================================================
//! File selection widget for directory tree navigation and file selection.
use crate::model::Model;
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, Paragraph},
};
/// State for the file selection widget - no longer needed, read directly from Model
pub type FileSelectionState = ();
/// Widget for file selection with directory tree, search, and filter patterns
pub struct FileSelectionWidget<'a> {
pub model: &'a Model,
}
impl<'a> FileSelectionWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> StatefulWidget for FileSelectionWidget<'a> {
type State = FileSelectionState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // File tree
Constraint::Length(3), // Search bar
Constraint::Length(3), // Pattern info
Constraint::Length(3), // Instructions
])
.split(area);
// File tree with scroll support - use new session-based approach
let mut session_clone = self.model.session.clone();
let visible_nodes = crate::utils::get_visible_nodes(
&self.model.file_tree_nodes,
&self.model.search_query,
&mut session_clone,
);
let total_nodes = visible_nodes.len();
// Calculate viewport dimensions
let tree_area = layout[0];
let content_height = tree_area.height.saturating_sub(2).max(1) as usize; // Account for borders, keep >= 1
// Derive a local, clamped scroll that keeps the cursor visible
let cursor = self.model.tree_cursor.min(total_nodes.saturating_sub(1));
let mut scroll_start = self.model.file_tree_scroll as usize;
if cursor < scroll_start {
scroll_start = cursor;
} else if cursor >= scroll_start.saturating_add(content_height) {
scroll_start = cursor.saturating_add(1).saturating_sub(content_height);
}
let max_scroll = total_nodes.saturating_sub(content_height);
scroll_start = scroll_start.min(max_scroll);
let scroll_end = (scroll_start + content_height).min(total_nodes);
// Create items only for visible viewport
let items: Vec = visible_nodes
.iter()
.enumerate()
.skip(scroll_start)
.take(content_height)
.map(|(i, display_node)| {
let node = &display_node.node;
let is_selected = display_node.is_selected;
let indent = " ".repeat(node.level);
let icon = if node.is_directory {
if node.is_expanded { "📂" } else { "📁" }
} else {
"📄"
};
let checkbox = if is_selected { "☑" } else { "☐" };
let content = format!("{}{} {} {}", indent, icon, checkbox, node.name);
let mut style = Style::default();
// Adjust cursor position for viewport
if i == cursor {
style = style.bg(Color::Blue).fg(Color::White);
}
if is_selected {
style = style.fg(Color::Green);
}
ListItem::new(content).style(style)
})
.collect();
// Create title with scroll indicator
let scroll_indicator = if total_nodes > content_height {
let current_start = scroll_start + 1;
let current_end = scroll_end;
format!(
"Files ({}) | Showing {}-{} of {}",
total_nodes, current_start, current_end, total_nodes
)
} else {
format!("Files ({})", total_nodes)
};
let tree_widget = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(scroll_indicator),
)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
Widget::render(tree_widget, layout[0], buf);
// Search bar - read directly from Model
let title_spans = vec![
Span::styled(
"s",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("earch", Style::default().fg(Color::White)),
Span::styled(" (text or * ? wildcards)", Style::default().fg(Color::Gray)),
];
let search_widget = Paragraph::new(self.model.search_query.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans)),
)
.style(
Style::default().fg(if self.model.search_query.contains('*') {
Color::Yellow
} else {
Color::Green
}),
);
Widget::render(search_widget, layout[1], buf);
// Pattern info
let include_text = if self.model.session.config.include_patterns.is_empty() {
"All files".to_string()
} else {
format!(
"Include: {}",
self.model.session.config.include_patterns.join(", ")
)
};
let exclude_text = if self.model.session.config.exclude_patterns.is_empty() {
"".to_string()
} else {
format!(
" | Exclude: {}",
self.model.session.config.exclude_patterns.join(", ")
)
};
let pattern_info = format!("{}{}", include_text, exclude_text);
let pattern_widget = Paragraph::new(pattern_info)
.block(
Block::default()
.borders(Borders::ALL)
.title("Filter Patterns"),
)
.style(Style::default().fg(Color::Cyan));
Widget::render(pattern_widget, layout[2], buf);
// Instructions
let instructions = Paragraph::new(
"Enter: Run Analysis | ↑↓: Navigate | Space: Select/Deselect | ←→: Expand/Collapse | PgUp/PgDn: Scroll | S: Search Mode | Esc: Exit"
)
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[3], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/mod.rs
================================================
//! Widget components for the TUI interface.
//!
//! This module contains all the widget implementations using Ratatui's native widget system.
//! Each widget is responsible for rendering a specific part of the UI and managing its own state.
pub mod file_selection;
pub mod output;
pub mod settings;
pub mod statistics_by_extension;
pub mod statistics_overview;
pub mod statistics_token_map;
pub mod template;
pub use file_selection::FileSelectionWidget;
pub use output::OutputWidget;
pub use settings::SettingsWidget;
pub use statistics_by_extension::StatisticsByExtensionWidget;
pub use statistics_overview::StatisticsOverviewWidget;
pub use statistics_token_map::StatisticsTokenMapWidget;
pub use template::TemplateWidget;
================================================
FILE: crates/code2prompt/src/widgets/output.rs
================================================
//! Output widget for displaying generated prompt with scrolling capability.
use crate::model::Model;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph, Wrap},
};
/// State for the output widget - no longer needed, read directly from Model
pub type OutputState = ();
/// Widget for output display with scrolling
pub struct OutputWidget<'a> {
pub model: &'a Model,
}
impl<'a> OutputWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> StatefulWidget for OutputWidget<'a> {
type State = OutputState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Info bar
Constraint::Min(0), // Prompt content
Constraint::Length(3), // Controls
])
.split(area);
// Simplified status bar - focus only on prompt availability
let info_text = if self.model.prompt_output.analysis_in_progress {
"Generating prompt...".to_string()
} else if let Some(error) = &self.model.prompt_output.analysis_error {
format!("Generation failed: {}", error)
} else if self.model.prompt_output.generated_prompt.is_some() {
"✓ Prompt ready! Copy (C) or Save (S)".to_string()
} else {
"Press Enter to generate prompt from selected files".to_string()
};
let info_widget = Paragraph::new(info_text)
.block(
Block::default()
.borders(Borders::ALL)
.title("Generated Prompt"),
)
.style(if self.model.prompt_output.analysis_error.is_some() {
Style::default().fg(Color::Red)
} else if self.model.prompt_output.analysis_in_progress {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
});
Widget::render(info_widget, layout[0], buf);
// Prompt content
let content = if self.model.prompt_output.analysis_in_progress {
"Generating prompt...".to_string()
} else if let Some(prompt) = &self.model.prompt_output.generated_prompt {
prompt.clone()
} else {
"Press to run analysis and generate prompt.\n\nSelected files will be processed according to your settings.".to_string()
};
// Compute viewport-aware scroll
let content_height = layout[1].height.saturating_sub(2).max(1) as usize; // borders
let (display_scroll, scroll_info) =
if let Some(prompt) = &self.model.prompt_output.generated_prompt {
let total_lines = prompt.lines().count();
let max_scroll = total_lines.saturating_sub(content_height);
let ds = self
.model
.prompt_output
.output_scroll
.min(max_scroll as u16);
let current_line = ds as usize + 1;
(
ds,
format!("Generated Prompt (Line {}/{})", current_line, total_lines),
)
} else {
(
self.model.prompt_output.output_scroll,
"Generated Prompt".to_string(),
)
};
let prompt_widget = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL).title(scroll_info))
.wrap(Wrap { trim: false })
.scroll((display_scroll, 0));
Widget::render(prompt_widget, layout[1], buf);
// Controls
let controls_text = if self.model.prompt_output.generated_prompt.is_some() {
"↑↓/PgUp/PgDn: Scroll | C: Copy | S: Save | Enter: Re-run"
} else {
"Enter: Run Analysis"
};
let controls_widget = Paragraph::new(controls_text)
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(controls_widget, layout[2], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/settings.rs
================================================
//! Settings widget for configuration management.
use crate::model::Model;
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, Paragraph},
};
/// State for the settings widget - no longer needed, read directly from Model
pub type SettingsState = ();
/// Widget for settings configuration
pub struct SettingsWidget<'a> {
pub model: &'a Model,
}
impl<'a> SettingsWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> StatefulWidget for SettingsWidget<'a> {
type State = SettingsState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let settings_groups = self.model.get_settings_groups();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Settings list
Constraint::Length(3), // Instructions
])
.split(area);
// Build grouped settings display
let mut items: Vec = Vec::new();
let mut item_index = 0;
for group in &settings_groups {
// Group header
items.push(
ListItem::new(format!("── {} ──", group.name)).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
// Group items
for item in &group.items {
let value_display = match &item.setting_type {
crate::model::SettingType::Boolean(val) => {
if *val {
"[●] ON".to_string()
} else {
"[○] OFF".to_string()
}
}
crate::model::SettingType::Choice { options, selected } => {
let current = options.get(*selected).cloned().unwrap_or_default();
let total = options.len();
format!("[▼ {} ({}/{})]", current, selected + 1, total)
}
};
// Better aligned layout: Name (20 chars) | Value (15 chars) | Description
let content = format!(
" {:<20} {:<15} {}",
item.name, value_display, item.description
);
let mut style = Style::default();
// Read cursor directly from Model
if item_index == self.model.settings.settings_cursor {
style = style
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD);
}
// Color based on setting type
match &item.setting_type {
crate::model::SettingType::Boolean(true) => {
style = style.fg(Color::Green);
}
crate::model::SettingType::Boolean(false) => {
style = style.fg(Color::Red);
}
crate::model::SettingType::Choice { .. } => {
style = style.fg(Color::Cyan);
}
}
items.push(ListItem::new(content).style(style));
item_index += 1;
}
// Add spacing between groups
items.push(ListItem::new(""));
}
let settings_widget = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Settings"))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
Widget::render(settings_widget, layout[0], buf);
// Instructions
let instructions = Paragraph::new(
"Enter: Run Analysis | ↑↓: Navigate | Space: Toggle | ←→: Cycle Options",
)
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/statistics_by_extension.rs
================================================
//! Statistics by extension widget for displaying extension-based histogram.
use crate::model::{Model, StatisticsState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
/// State for the extension statistics widget - eliminated redundant state
pub type ExtensionState = ();
/// Widget for extension-based statistics display
pub struct StatisticsByExtensionWidget<'a> {
pub model: &'a Model,
}
impl<'a> StatisticsByExtensionWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> StatefulWidget for StatisticsByExtensionWidget<'a> {
type State = ExtensionState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Extension statistics content
Constraint::Length(3), // Instructions
])
.split(area);
let title = "📁 By Extension";
if self.model.statistics.token_map_entries.is_empty() {
let placeholder_text = if self.model.prompt_output.generated_prompt.is_some() {
"\nNo token map data available.\n\nPress Enter to re-run analysis."
} else {
"\nRun analysis first to see token breakdown by file extension.\n\nPress Enter to run analysis."
};
let placeholder_widget = Paragraph::new(placeholder_text)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
Widget::render(placeholder_widget, layout[0], buf);
// Instructions
let instructions =
Paragraph::new("Enter: Run Analysis | ←→: Switch View | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
return;
}
// Use business logic from Model - pure Elm/Redux pattern
let ext_vec = self.model.statistics.aggregate_by_extension();
let total_tokens = self.model.prompt_output.token_count.unwrap_or(0);
// Calculate viewport for scrolling - read directly from Model
let content_height = layout[0].height.saturating_sub(2).max(1) as usize;
let total = ext_vec.len();
let max_scroll = total.saturating_sub(content_height);
let scroll_start = (self.model.statistics.scroll as usize).min(max_scroll);
let scroll_end = (scroll_start + content_height).min(total);
// Calculate dynamic column widths based on available space and content
let available_width = layout[0].width.saturating_sub(4) as usize; // Account for borders and padding
// Calculate maximum widths needed for each column
let max_ext_width = ext_vec
.iter()
.map(|(ext, _, _)| ext.len())
.max()
.unwrap_or(12)
.max(12); // Minimum 12 chars for "Extension"
let max_tokens_width = ext_vec
.iter()
.map(|(_, tokens, _)| {
StatisticsState::format_number(*tokens, &self.model.session.config.token_format)
.len()
})
.max()
.unwrap_or(6)
.max(6); // Minimum 6 chars for tokens
let max_count_width = ext_vec
.iter()
.map(|(_, _, count)| count.to_string().len())
.max()
.unwrap_or(3)
.max(3); // Minimum 3 chars for count
// Fixed widths for percentage and separators
let percentage_width = 7; // "(100.0%)"
let separators_width = 8; // " │ │ " + " | " + " files"
// Calculate remaining space for the progress bar
let fixed_content_width = max_ext_width
+ max_tokens_width
+ percentage_width
+ max_count_width
+ separators_width
+ 5; // +5 for "files"
let bar_width = if available_width > fixed_content_width {
(available_width - fixed_content_width).clamp(10, 40) // Between 10 and 40 chars
} else {
15 // Fallback minimum bar width
};
// Create list items with dynamic formatting
let items: Vec = ext_vec
.iter()
.skip(scroll_start)
.take(content_height)
.map(|(extension, tokens, count)| {
let percentage = if total_tokens > 0 {
(*tokens as f64 / total_tokens as f64) * 100.0
} else {
0.0
};
// Create visual bar with calculated width
let filled_chars = ((percentage / 100.0) * bar_width as f64) as usize;
let bar = format!(
"{}{}",
"█".repeat(filled_chars),
"░".repeat(bar_width.saturating_sub(filled_chars))
);
// Choose color based on extension
let color = match extension.as_str() {
".rs" => Color::LightRed,
".md" | ".txt" | ".rst" => Color::Green,
".toml" | ".json" | ".yaml" | ".yml" => Color::Magenta,
".js" | ".ts" | ".jsx" | ".tsx" => Color::Cyan,
".py" => Color::LightYellow,
".go" => Color::LightBlue,
".java" | ".kt" => Color::Red,
".cpp" | ".c" | ".h" => Color::Blue,
_ => Color::White,
};
// Format with dynamic column widths
let formatted_tokens = StatisticsState::format_number(
*tokens,
&self.model.session.config.token_format,
);
let content = format!(
"{:width_tokens$} ({:>4.1}%) | {:>width_count$} files",
extension,
bar,
formatted_tokens,
percentage,
count,
width_ext = max_ext_width,
width_tokens = max_tokens_width,
width_count = max_count_width
);
ListItem::new(content).style(Style::default().fg(color))
})
.collect();
// Create title with scroll indicator
let scroll_title = if ext_vec.len() > content_height {
format!(
"{} | Showing {}-{} of {}",
title,
scroll_start + 1,
scroll_end,
ext_vec.len()
)
} else {
title.to_string()
};
// Add header row for better column alignment
let header = format!(
"{:width_tokens$} {:>7} | {:>width_count$} Files",
"Extension",
"Usage",
"Tokens",
"Percent",
"Count",
width_ext = max_ext_width,
width_bar = bar_width,
width_tokens = max_tokens_width,
width_count = max_count_width
);
let mut all_items = vec![
ListItem::new(header).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
ListItem::new("─".repeat(available_width.min(120)))
.style(Style::default().fg(Color::DarkGray)),
];
all_items.extend(items);
let extensions_widget = List::new(all_items)
.block(Block::default().borders(Borders::ALL).title(scroll_title))
.style(Style::default().fg(Color::White));
Widget::render(extensions_widget, layout[0], buf);
// Instructions
let instructions = Paragraph::new("Enter: Run Analysis | ←→: Switch View | ↑↓/PgUp/PgDn: Scroll | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/statistics_overview.rs
================================================
//! Statistics overview widget for displaying analysis summary.
use crate::model::{Model, StatisticsState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
/// Widget for statistics overview (stateless)
pub struct StatisticsOverviewWidget<'a> {
pub model: &'a Model,
}
impl<'a> StatisticsOverviewWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> Widget for StatisticsOverviewWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Statistics content
Constraint::Length(3), // Instructions
])
.split(area);
// Check if analysis has been run
if self.model.prompt_output.generated_prompt.is_none()
&& !self.model.prompt_output.analysis_in_progress
{
// Show placeholder when no analysis has been run
let placeholder_text =
"\nNo analysis data available yet.\n\nPress Enter to run analysis.";
let placeholder_widget = Paragraph::new(placeholder_text)
.block(Block::default().borders(Borders::ALL).title("📊 Overview"))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
Widget::render(placeholder_widget, layout[0], buf);
// Instructions for when no analysis is available
let instructions = Paragraph::new("Enter: Go to Selection | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
return;
}
let mut stats_items: Vec = Vec::new();
// Analysis Status (most important first)
let (status_text, status_color) = if self.model.prompt_output.analysis_in_progress {
("Generating prompt...".to_string(), Color::Yellow)
} else if self.model.prompt_output.analysis_error.is_some() {
("Analysis failed".to_string(), Color::Red)
} else if self.model.prompt_output.generated_prompt.is_some() {
("Analysis complete".to_string(), Color::Green)
} else {
("Ready to analyze".to_string(), Color::Gray)
};
stats_items.push(
ListItem::new(format!("Status: {}", status_text)).style(
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
);
if let Some(error) = &self.model.prompt_output.analysis_error {
stats_items.push(
ListItem::new(format!(" Error: {}", error)).style(Style::default().fg(Color::Red)),
);
}
stats_items.push(ListItem::new(""));
// File Summary
stats_items.push(
ListItem::new("📁 File Summary").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
let mut session_clone = self.model.session.clone();
let selected_count = StatisticsState::count_selected_files(&mut session_clone);
let eligible_count = StatisticsState::count_total_files(&self.model.file_tree_nodes);
let total_files = self.model.prompt_output.file_count;
stats_items.push(ListItem::new(format!(
" • Selected (current): {} files",
selected_count
)));
stats_items.push(ListItem::new(format!(
" • Eligible (current filters): {} files",
eligible_count
)));
stats_items.push(ListItem::new(format!(
" • Included (last run): {} files",
total_files
)));
if selected_count > 0 && eligible_count > 0 {
let percentage = (selected_count as f64 / eligible_count as f64 * 100.0) as usize;
stats_items.push(ListItem::new(format!(
" • Selection Rate (current): {}%",
percentage
)));
}
stats_items.push(ListItem::new(""));
// Token Summary
stats_items.push(
ListItem::new("🎯 Token Summary").style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
);
if let Some(token_count) = self.model.prompt_output.token_count {
stats_items.push(ListItem::new(format!(
" • Total Tokens: {}",
StatisticsState::format_number(
token_count,
&self.model.session.config.token_format
)
)));
if selected_count > 0 {
let avg_tokens = token_count / selected_count;
stats_items.push(ListItem::new(format!(
" • Avg per File: {}",
StatisticsState::format_number(
avg_tokens,
&self.model.session.config.token_format
)
)));
}
} else {
stats_items.push(ListItem::new(" • Total Tokens: Not calculated"));
}
stats_items.push(ListItem::new(""));
// Configuration Summary
stats_items.push(
ListItem::new("⚙️ Configuration").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
let output_format = match self.model.session.config.output_format {
code2prompt_core::template::OutputFormat::Markdown => "Markdown",
code2prompt_core::template::OutputFormat::Json => "JSON",
code2prompt_core::template::OutputFormat::Xml => "XML",
};
stats_items.push(ListItem::new(format!(" • Output: {}", output_format)));
stats_items.push(ListItem::new(format!(
" • Line Numbers: {}",
if self.model.session.config.line_numbers {
"On"
} else {
"Off"
}
)));
stats_items.push(ListItem::new(format!(
" • Git Diff: {}",
if self.model.session.config.diff_enabled {
"On"
} else {
"Off"
}
)));
let pattern_summary = format!(
" • Patterns: {} include, {} exclude",
self.model.session.config.include_patterns.len(),
self.model.session.config.exclude_patterns.len()
);
stats_items.push(ListItem::new(pattern_summary));
let stats_widget = List::new(stats_items)
.block(Block::default().borders(Borders::ALL).title("📊 Overview"))
.style(Style::default().fg(Color::White));
Widget::render(stats_widget, layout[0], buf);
// Instructions
let instructions =
Paragraph::new("Enter: Run Analysis | ←→: Switch View | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/statistics_token_map.rs
================================================
//! Statistics token map widget for displaying token distribution.
use crate::model::Model;
use crate::token_map::{TuiColor, format_token_map_for_tui};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
/// State for the token map widget - no longer needed, read directly from Model
pub type TokenMapState = ();
/// Widget for token map display
pub struct StatisticsTokenMapWidget<'a> {
pub model: &'a Model,
}
impl<'a> StatisticsTokenMapWidget<'a> {
pub fn new(model: &'a Model) -> Self {
Self { model }
}
}
impl<'a> StatefulWidget for StatisticsTokenMapWidget<'a> {
type State = TokenMapState;
fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Token map content
Constraint::Length(3), // Instructions
])
.split(area);
let title = "🗂️ Token Map";
if self.model.statistics.token_map_entries.is_empty() {
let placeholder_text = if self.model.prompt_output.generated_prompt.is_some() {
"\nNo token map data available.\n\nPress Enter to re-run analysis."
} else {
"\nRun analysis first to see token distribution.\n\nPress Enter to run analysis."
};
let placeholder_widget = Paragraph::new(placeholder_text)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
Widget::render(placeholder_widget, layout[0], buf);
// Instructions
let instructions =
Paragraph::new("Enter: Run Analysis | ←→: Switch View | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
return;
}
// Use the shared token map formatting logic from token_map.rs with adaptive layout
let total_tokens = self.model.prompt_output.token_count.unwrap_or(0);
let terminal_width = area.width as usize;
let formatted_lines = format_token_map_for_tui(
&self.model.statistics.token_map_entries,
total_tokens,
terminal_width,
);
// Calculate viewport for scrolling - read directly from Model
let content_height = layout[0].height.saturating_sub(2).max(1) as usize; // Account for borders
let total = formatted_lines.len();
let max_scroll = total.saturating_sub(content_height);
let scroll_start = (self.model.statistics.scroll as usize).min(max_scroll);
let scroll_end = (scroll_start + content_height).min(formatted_lines.len());
// Convert formatted lines to ListItems with proper column layout and filename coloring
let items: Vec = formatted_lines
.iter()
.skip(scroll_start)
.take(content_height)
.map(|line| {
// Convert TuiColor to ratatui Color for filename only
let name_color = match line.name_color {
TuiColor::White => Color::White,
TuiColor::Gray => Color::Gray,
TuiColor::Red => Color::Red,
TuiColor::Green => Color::Green,
TuiColor::Blue => Color::Blue,
TuiColor::Yellow => Color::Yellow,
TuiColor::Cyan => Color::Cyan,
TuiColor::Magenta => Color::Magenta,
TuiColor::LightRed => Color::LightRed,
TuiColor::LightGreen => Color::LightGreen,
TuiColor::LightBlue => Color::LightBlue,
TuiColor::LightYellow => Color::LightYellow,
TuiColor::LightCyan => Color::LightCyan,
TuiColor::LightMagenta => Color::LightMagenta,
};
// Create spans with proper coloring - only filename gets color, rest is white
let spans = vec![
Span::styled(&line.tokens_part, Style::default().fg(Color::White)),
Span::styled(" ", Style::default().fg(Color::White)), // spacing
Span::styled(&line.prefix_part, Style::default().fg(Color::White)),
Span::styled(&line.name_part, Style::default().fg(name_color)), // Only filename colored
Span::styled(" ", Style::default().fg(Color::White)), // spacing
Span::styled(&line.bar_part, Style::default().fg(Color::White)),
Span::styled(" ", Style::default().fg(Color::White)), // spacing
Span::styled(&line.percentage_part, Style::default().fg(Color::White)),
];
ListItem::new(Line::from(spans))
})
.collect();
// Create title with scroll indicator
let scroll_title = if formatted_lines.len() > content_height {
format!(
"{} | Showing {}-{} of {}",
title,
scroll_start + 1,
scroll_end,
formatted_lines.len()
)
} else {
title.to_string()
};
let token_map_widget =
List::new(items).block(Block::default().borders(Borders::ALL).title(scroll_title));
Widget::render(token_map_widget, layout[0], buf);
// Instructions
let instructions = Paragraph::new("Enter: Run Analysis | ←→: Switch View | ↑↓/PgUp/PgDn: Scroll | Tab/Shift+Tab: Switch Tab")
.block(Block::default().borders(Borders::ALL).title("Controls"))
.style(Style::default().fg(Color::Gray));
Widget::render(instructions, layout[1], buf);
}
}
================================================
FILE: crates/code2prompt/src/widgets/template/editor.rs
================================================
//! Template Editor sub-widget.
//!
//! This widget provides an editable text area for template content with validation.
use crate::model::template::EditorState;
use ratatui::{
prelude::*,
widgets::{Block, Borders},
};
/// Template Editor sub-widget
pub struct TemplateEditorWidget;
impl TemplateEditorWidget {
pub fn new() -> Self {
Self
}
/// Render the template editor
pub fn render(
&self,
area: Rect,
buf: &mut Buffer,
state: &mut EditorState,
is_focused: bool,
has_missing_vars: bool,
) {
// Determine border style based on validation and focus
let border_style = if is_focused {
Style::default().fg(Color::Yellow) // Focused
} else {
Style::default().fg(Color::Rgb(139, 69, 19)) // Brown for normal
};
// Create title with validation status
let title_spans = if !state.is_valid {
vec![
Span::styled("Template ", Style::default().fg(Color::White)),
Span::styled(
"e",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("ditor ", Style::default().fg(Color::White)),
Span::styled(
format!("(SYNTAX ERROR: {})", state.validation_message),
Style::default().fg(Color::Red),
),
]
} else if has_missing_vars {
vec![
Span::styled("Template ", Style::default().fg(Color::White)),
Span::styled(
"e",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("ditor ", Style::default().fg(Color::White)),
Span::styled(" (MISSING VARIABLES)", Style::default().fg(Color::Red)),
]
} else {
vec![
Span::styled("Template ", Style::default().fg(Color::White)),
Span::styled(
"e",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("ditor ", Style::default().fg(Color::White)),
Span::styled(" (VALID)", Style::default().fg(Color::Green)),
]
};
// Configure TextArea
let mut textarea = state.editor.clone();
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans))
.border_style(border_style),
);
// Set cursor and text styles based on focus and validation
if is_focused {
textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED));
textarea.set_cursor_style(Style::default().fg(Color::Yellow));
}
// Set text color - always use brown highlight for invalid, white for valid
if !state.is_valid || has_missing_vars {
textarea.set_style(Style::default().fg(Color::Rgb(139, 69, 19))); // Brown highlight
} else {
textarea.set_style(Style::default().fg(Color::White));
}
// Render the TextArea
Widget::render(&textarea, area, buf);
}
}
impl Default for TemplateEditorWidget {
fn default() -> Self {
Self::new()
}
}
================================================
FILE: crates/code2prompt/src/widgets/template/mod.rs
================================================
//! Template widget module.
//!
//! This module coordinates the three template sub-widgets:
//! - Editor: Template content editing and validation
//! - Variable: Variable management and validation
//! - Picker: Template selection and loading
pub mod editor;
pub mod picker;
pub mod variable;
pub use editor::TemplateEditorWidget;
pub use picker::TemplatePickerWidget;
pub use variable::TemplateVariableWidget;
use crate::model::Model;
use crate::model::template::{TemplateFocus, TemplateState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph},
};
/// Main Template widget that coordinates the 3 sub-widgets
pub struct TemplateWidget {
editor: TemplateEditorWidget,
variables: TemplateVariableWidget,
picker: TemplatePickerWidget,
}
impl TemplateWidget {
pub fn new(_model: &Model) -> Self {
Self {
editor: TemplateEditorWidget::new(),
variables: TemplateVariableWidget::new(),
picker: TemplatePickerWidget::new(),
}
}
/// Render the template widget with 3 columns
pub fn render(&self, area: Rect, buf: &mut Buffer, state: &mut TemplateState) {
// Main layout - content and footer
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Content (3 columns)
Constraint::Length(3), // Footer
])
.split(area);
// 3-column layout for content
self.render_content(chunks[0], buf, state);
// Footer
self.render_footer(chunks[1], buf, state);
}
/// Render the 3-column content area
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut TemplateState) {
// Flexible 3-column layout
let min_width = 30;
let available_width = area.width.saturating_sub(6); // Account for borders
let constraints = if available_width >= min_width * 3 {
// Full 3-column layout
vec![
Constraint::Percentage(40), // Editor
Constraint::Percentage(35), // Variables
Constraint::Percentage(25), // Picker
]
} else if available_width >= min_width * 2 {
// 2-column layout, hide picker or make it smaller
vec![
Constraint::Percentage(60), // Editor
Constraint::Percentage(40), // Variables
Constraint::Length(0), // Picker hidden
]
} else {
// Single column, show only focused column
match state.get_focus() {
TemplateFocus::Editor => vec![
Constraint::Percentage(100),
Constraint::Length(0),
Constraint::Length(0),
],
TemplateFocus::Variables => vec![
Constraint::Length(0),
Constraint::Percentage(100),
Constraint::Length(0),
],
TemplateFocus::Picker => vec![
Constraint::Length(0),
Constraint::Length(0),
Constraint::Percentage(100),
],
}
};
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
// Render each column if it has space
if columns[0].width > 0 {
let is_editor_focused = state.get_focus() == TemplateFocus::Editor;
let is_editing_template =
state.get_focus_mode() == crate::model::template::FocusMode::EditingTemplate;
let has_missing_vars = state.variables.has_missing_variables();
self.editor.render(
columns[0],
buf,
&mut state.editor,
is_editor_focused || is_editing_template,
has_missing_vars,
);
}
if columns[1].width > 0 {
let variables = state.get_organized_variables();
let is_variables_focused = state.get_focus() == TemplateFocus::Variables;
let is_editing_variable =
state.get_focus_mode() == crate::model::template::FocusMode::EditingVariable;
self.variables.render(
columns[1],
buf,
&state.variables,
&variables,
is_variables_focused || is_editing_variable,
);
}
if columns[2].width > 0 {
self.picker.render(
columns[2],
buf,
&state.picker,
state.get_focus() == TemplateFocus::Picker,
);
}
}
/// Render the footer with controls and status
fn render_footer(&self, area: Rect, buf: &mut Buffer, state: &TemplateState) {
let footer_content = if !state.get_status().is_empty() {
// Simple text for status messages
vec![Span::styled(
state.get_status(),
Style::default().fg(Color::Gray),
)]
} else {
// Show different controls based on focus mode
match state.get_focus_mode() {
crate::model::template::FocusMode::Normal => {
// Normal mode: can switch focus with colored letters
let mut spans = vec![
Span::styled(
"Enter: Run Analysis | Focus: ",
Style::default().fg(Color::Gray),
),
Span::styled(
"e",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("(dit) ", Style::default().fg(Color::Gray)),
Span::styled(
"v",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("(ariables) ", Style::default().fg(Color::Gray)),
Span::styled(
"p",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("(icker) | ", Style::default().fg(Color::Gray)),
Span::styled(
"s",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("(ave Template) ", Style::default().fg(Color::Gray)),
];
let specific_controls = match state.get_focus() {
TemplateFocus::Editor => "",
TemplateFocus::Variables => "",
TemplateFocus::Picker => {
TemplatePickerWidget::get_help_text(true, state.picker.active_list)
}
};
spans.push(Span::styled(
specific_controls,
Style::default().fg(Color::Gray),
));
spans
}
crate::model::template::FocusMode::EditingTemplate => {
vec![Span::styled(
"EDIT MODE: Type to edit template | ESC: Exit edit mode",
Style::default().fg(Color::Gray),
)]
}
crate::model::template::FocusMode::EditingVariable => {
let text = if state.variables.is_editing() {
"VARIABLE INPUT: Type value | Enter: Save | ESC: Cancel"
} else {
"VARIABLE MODE: ↑↓: Navigate | Space: Edit variable | Tab: Next | ESC: Exit"
};
vec![Span::styled(text, Style::default().fg(Color::Gray))]
}
}
};
let footer = Paragraph::new(Line::from(footer_content))
.block(Block::default().borders(Borders::ALL).title("Controls"));
footer.render(area, buf);
}
}
impl StatefulWidget for TemplateWidget {
type State = TemplateState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
TemplateWidget::render(&self, area, buf, state);
}
}
================================================
FILE: crates/code2prompt/src/widgets/template/picker.rs
================================================
//! Template Picker sub-widget.
//!
//! This widget provides template selection with separate default and custom lists.
use crate::model::template::{ActiveList, PickerState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem},
};
/// Template Picker sub-widget
pub struct TemplatePickerWidget;
impl TemplatePickerWidget {
pub fn new() -> Self {
Self
}
/// Render the template picker as a single unified list with groups
pub fn render(&self, area: Rect, buf: &mut Buffer, state: &PickerState, is_focused: bool) {
let border_style = if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
};
// Create unified list with section headers
let mut items = Vec::new();
let mut item_index = 0;
let global_cursor = state.get_global_cursor_position();
// Default Templates Section
if !state.default_templates.is_empty() {
// Section header
items.push(ListItem::new(Line::from(vec![
Span::styled("📄 ", Style::default().fg(Color::White)),
Span::styled(
"Default Templates",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
])));
item_index += 1;
// Default template items
for template in state.default_templates.iter() {
let is_selected = global_cursor == item_index;
let style = if is_selected && is_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { "► " } else { " " };
items.push(ListItem::new(format!("{}📄 {}", prefix, template.name)).style(style));
item_index += 1;
}
}
// Custom Templates Section
if !state.custom_templates.is_empty() {
// Add separator if we have default templates
if !state.default_templates.is_empty() {
items.push(ListItem::new(""));
item_index += 1;
}
// Section header
items.push(ListItem::new(Line::from(vec![
Span::styled("📝 ", Style::default().fg(Color::White)),
Span::styled(
"Custom Templates",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])));
item_index += 1;
// Custom template items
for template in state.custom_templates.iter() {
let is_selected = global_cursor == item_index;
let style = if is_selected && is_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { "► " } else { " " };
items.push(ListItem::new(format!("{}📝 {}", prefix, template.name)).style(style));
item_index += 1;
}
}
// Create title with focus indicators
let title_spans = vec![
Span::styled("Template ", Style::default().fg(Color::White)),
Span::styled(
"p",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("icker", Style::default().fg(Color::White)),
];
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans))
.border_style(border_style),
);
Widget::render(list, area, buf);
}
/// Get help text for the picker
pub fn get_help_text(is_focused: bool, _active_list: ActiveList) -> &'static str {
if is_focused {
"↑↓: Navigate | l/Space: Load | r: Refresh"
} else {
"Press 'p' to focus picker"
}
}
}
impl Default for TemplatePickerWidget {
fn default() -> Self {
Self::new()
}
}
================================================
FILE: crates/code2prompt/src/widgets/template/variable.rs
================================================
//! Template Variable sub-widget.
//!
//! This widget provides a 2-column display for template variables with direct editing.
use crate::model::template::{VariableCategory, VariableInfo, VariableState};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, Paragraph},
};
/// Template Variable sub-widget
pub struct TemplateVariableWidget;
impl TemplateVariableWidget {
pub fn new() -> Self {
Self
}
/// Render the variable widget
pub fn render(
&self,
area: Rect,
buf: &mut Buffer,
state: &VariableState,
variables: &[VariableInfo],
is_focused: bool,
) {
let border_style = if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
};
// Create table-like display with 2 columns
let mut lines = Vec::new();
// Header
lines.push(Line::from(vec![
Span::styled(
"Name",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "), // Spacing
Span::styled(
"Description/Value",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![Span::raw(
"────────────────────────────────────────────────────────────────────────────────",
)]));
// Variable rows
for (i, var_info) in variables.iter().enumerate() {
let is_selected = i == state.cursor && is_focused;
let name_style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
match var_info.category {
VariableCategory::System => Style::default().fg(Color::Green),
VariableCategory::User => Style::default().fg(Color::Cyan),
VariableCategory::Missing => Style::default().fg(Color::Red),
}
};
let value_style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = match var_info.category {
VariableCategory::System => "🔧 ",
VariableCategory::User => "👤 ",
VariableCategory::Missing => "❌ ",
};
let name_part = format!("{}{{{{{}}}}}", prefix, var_info.name);
let name_padded = format!("{:<24}", name_part);
let value_part = match var_info.category {
VariableCategory::System => var_info
.description
.as_ref()
.unwrap_or(&"System variable".to_string())
.clone(),
VariableCategory::User => var_info
.value
.as_ref()
.unwrap_or(&"(empty)".to_string())
.clone(),
VariableCategory::Missing => "⚠️ Not defined".to_string(), // NO "Press Enter to set"
};
let line = if is_selected {
// Highlight entire row for selected item
Line::from(vec![Span::styled(
format!("► {}{}", name_padded, value_part),
name_style,
)])
} else {
Line::from(vec![
Span::styled(format!(" {}", name_padded), name_style),
Span::styled(value_part, value_style),
])
};
lines.push(line);
}
let title_spans = vec![
Span::styled(
"v",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("ariables", Style::default().fg(Color::White)),
];
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans))
.border_style(border_style),
)
.wrap(ratatui::widgets::Wrap { trim: false });
Widget::render(paragraph, area, buf);
// Render variable input popup if active
if state.is_editing() {
self.render_variable_input(area, buf, state);
}
}
/// Render variable input popup
fn render_variable_input(&self, area: Rect, buf: &mut Buffer, state: &VariableState) {
let popup_area = Self::centered_rect(60, 20, area);
Clear.render(popup_area, buf);
let var_name = state
.get_editing_variable()
.map(|s| s.as_str())
.unwrap_or("Unknown");
let title = format!("Set Variable: {}", var_name);
let paragraph = Paragraph::new(state.get_input_content()).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
);
Widget::render(paragraph, popup_area, buf);
}
/// Create centered rectangle for popup
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
}
impl Default for TemplateVariableWidget {
fn default() -> Self {
Self::new()
}
}
================================================
FILE: crates/code2prompt/tests/common/fixtures.rs
================================================
//! rstest fixtures for code2prompt integration tests
use super::test_env::*;
use colored::*;
use log::info;
use rstest::*;
use std::fs;
/// Fixture for basic test environment with standard file hierarchy
#[fixture]
pub fn basic_test_env() -> BasicTestEnv {
let env = BasicTestEnv::new();
create_standard_hierarchy(env.dir.path());
env
}
/// Fixture for git test environment with gitignore setup
#[fixture]
pub fn git_test_env() -> GitTestEnv {
let env = GitTestEnv::new();
create_git_hierarchy(env.dir.path());
env
}
/// Fixture for stdout test environment with simple files
#[fixture]
pub fn stdout_test_env() -> StdoutTestEnv {
let env = StdoutTestEnv::new();
create_simple_test_files(env.dir.path());
env
}
/// Fixture for template test environment with code structure
#[fixture]
pub fn template_test_env() -> TemplateTestEnv {
let env = TemplateTestEnv::new();
create_test_codebase(env.dir.path());
env
}
/// Create standard test hierarchy (lowercase/uppercase directories with various files)
pub fn create_standard_hierarchy(base_path: &std::path::Path) {
let lowercase_dir = base_path.join("lowercase");
let uppercase_dir = base_path.join("uppercase");
fs::create_dir_all(&lowercase_dir).unwrap();
fs::create_dir_all(&uppercase_dir).unwrap();
let files = vec![
("lowercase/foo.py", "content foo.py"),
("lowercase/bar.py", "content bar.py"),
("lowercase/baz.py", "content baz.py"),
("lowercase/qux.txt", "content qux.txt"),
("lowercase/corge.txt", "content corge.txt"),
("lowercase/grault.txt", "content grault.txt"),
("uppercase/FOO.py", "CONTENT FOO.PY"),
("uppercase/BAR.py", "CONTENT BAR.PY"),
("uppercase/BAZ.py", "CONTENT BAZ.PY"),
("uppercase/QUX.txt", "CONTENT QUX.TXT"),
("uppercase/CORGE.txt", "CONTENT CORGE.TXT"),
("uppercase/GRAULT.txt", "CONTENT GRAULT.TXT"),
];
for (file_path, content) in files {
create_temp_file(base_path, file_path, content);
}
info!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
"Standard test hierarchy created".green()
);
}
/// Create git test hierarchy with gitignore
pub fn create_git_hierarchy(base_path: &std::path::Path) {
let test_dir = base_path.join("test_dir");
fs::create_dir_all(&test_dir).unwrap();
let files = vec![
("test_dir/included.txt", "Included file"),
("test_dir/ignored.txt", "Ignored file"),
];
for (file_path, content) in files {
create_temp_file(base_path, file_path, content);
}
// Create a .gitignore file
let gitignore_path = base_path.join(".gitignore");
let mut gitignore_file =
std::fs::File::create(&gitignore_path).expect("Failed to create .gitignore file");
use std::io::Write;
writeln!(gitignore_file, "test_dir/ignored.txt").expect("Failed to write to .gitignore file");
info!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
"Git test hierarchy created".green()
);
}
/// Create simple test files for stdout tests
pub fn create_simple_test_files(base_path: &std::path::Path) {
let files = vec![
("test.py", "print('Hello, World!')"),
("README.md", "# Test Project\nThis is a test."),
("config.json", r#"{"name": "test", "version": "1.0.0"}"#),
];
for (file_path, content) in files {
create_temp_file(base_path, file_path, content);
}
info!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
"Simple test files created".green()
);
}
/// Create test codebase for template tests
pub fn create_test_codebase(base_path: &std::path::Path) {
let files = vec![
(
"src/main.rs",
"fn main() {\n println!(\"Hello, world!\");\n}",
),
(
"src/lib.rs",
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}",
),
(
"tests/test.rs",
"#[test]\nfn test_add() {\n assert_eq!(3, add(1, 2));\n}",
),
];
for (file_path, content) in files {
create_temp_file(base_path, file_path, content);
}
info!(
"{}{}{} {}",
"[".bold().white(),
"✓".bold().green(),
"]".bold().white(),
"Test codebase created".green()
);
}
================================================
FILE: crates/code2prompt/tests/common/mod.rs
================================================
//! Common test utilities and fixtures for code2prompt integration tests
//!
//! This module provides reusable fixtures and utilities to reduce code duplication
//! across integration tests using rstest.
pub mod fixtures;
pub mod test_env;
pub use test_env::*;
use std::sync::Once;
static INIT: Once = Once::new();
/// Initialize logger for tests (called once)
pub fn init_logger() {
INIT.call_once(|| {
env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Debug)
.try_init()
.expect("Failed to initialize logger");
});
}
================================================
FILE: crates/code2prompt/tests/common/test_env.rs
================================================
//! Test environment types and utilities
#![allow(dead_code)]
use assert_cmd::Command;
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
/// Basic test environment with temporary directory and output file
pub struct BasicTestEnv {
pub dir: TempDir,
output_file: String,
}
impl BasicTestEnv {
pub fn new() -> Self {
super::init_logger();
let dir = tempfile::tempdir().unwrap();
let output_file = dir.path().join("output.txt").to_str().unwrap().to_string();
BasicTestEnv { dir, output_file }
}
pub fn command(&self) -> Command {
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(self.dir.path().to_str().unwrap())
.arg("--output-file")
.arg(&self.output_file)
.arg("--no-clipboard");
cmd
}
pub fn read_output(&self) -> String {
let file_path = self.dir.path().join("output.txt");
std::fs::read_to_string(&file_path)
.unwrap_or_else(|_| panic!("Failed to read output file: {:?}", file_path))
}
}
/// Git-enabled test environment
pub struct GitTestEnv {
pub dir: TempDir,
output_file: String,
}
impl GitTestEnv {
pub fn new() -> Self {
super::init_logger();
let dir = tempfile::tempdir().unwrap();
let _repo = git2::Repository::init(dir.path()).expect("Failed to initialize repository");
let output_file = dir.path().join("output.txt").to_str().unwrap().to_string();
GitTestEnv { dir, output_file }
}
pub fn command(&self) -> Command {
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(self.dir.path().to_str().unwrap())
.arg("--output-file")
.arg(&self.output_file)
.arg("--no-clipboard");
cmd
}
pub fn read_output(&self) -> String {
let file_path = self.dir.path().join("output.txt");
std::fs::read_to_string(&file_path)
.unwrap_or_else(|_| panic!("Failed to read output file: {:?}", file_path))
}
}
/// Simple test environment for stdout tests
pub struct StdoutTestEnv {
pub dir: TempDir,
}
impl StdoutTestEnv {
pub fn new() -> Self {
super::init_logger();
let dir = tempfile::tempdir().unwrap();
StdoutTestEnv { dir }
}
pub fn path(&self) -> &str {
self.dir.path().to_str().unwrap()
}
}
/// Template test environment
pub struct TemplateTestEnv {
pub dir: TempDir,
output_file: std::path::PathBuf,
}
impl TemplateTestEnv {
pub fn new() -> Self {
super::init_logger();
let dir = tempfile::tempdir().unwrap();
let output_file = dir.path().join("output.txt");
TemplateTestEnv { dir, output_file }
}
pub fn command(&self) -> Command {
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(self.dir.path().to_str().unwrap())
.arg("--output-file")
.arg(self.output_file.to_str().unwrap())
.arg("--no-clipboard");
cmd
}
pub fn read_output(&self) -> String {
std::fs::read_to_string(&self.output_file)
.unwrap_or_else(|_| panic!("Failed to read output file: {:?}", self.output_file))
}
pub fn output_file_exists(&self) -> bool {
self.output_file.exists()
}
}
/// Utility functions
pub fn create_temp_file(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
let file_path = dir.join(name);
let parent_dir = file_path.parent().unwrap();
fs::create_dir_all(parent_dir)
.unwrap_or_else(|_| panic!("Failed to create directory: {:?}", parent_dir));
let mut file = File::create(&file_path)
.unwrap_or_else(|_| panic!("Failed to create temp file: {:?}", file_path));
writeln!(file, "{}", content)
.unwrap_or_else(|_| panic!("Failed to write to temp file: {:?}", file_path));
file_path
}
================================================
FILE: crates/code2prompt/tests/config_test.rs
================================================
//! Tests for TOML configuration functionality
//!
//! This module tests the TOML configuration loading, parsing, and integration
//! with the new Unix-style behavior.
mod common;
use code2prompt_core::sort::FileSortMethod;
use code2prompt_core::template::OutputFormat;
use common::*;
use predicates::prelude::*;
use predicates::str::contains;
use std::fs;
use tempfile::TempDir;
/// Test TOML configuration parsing
#[test]
fn test_toml_config_parsing() {
let toml_content = r#"
default_output = "clipboard"
path = "./src"
include_patterns = ["*.rs", "*.toml"]
exclude_patterns = ["target", "node_modules"]
line_numbers = true
absolute_path = false
full_directory_tree = false
output_format = "markdown"
sort_method = "name_asc"
encoding = "cl100k"
token_format = "format"
diff_enabled = true
diff_branches = ["main", "feature-x"]
log_branches = ["v1.0.0", "v1.1.0"]
template_name = "default"
template_str = ""
token_map_enabled = true
[user_variables]
project = "code2prompt"
author = "ODAncona"
"#;
use code2prompt_core::configuration::TomlConfig;
let config = TomlConfig::from_toml_str(toml_content).expect("Should parse TOML config");
assert_eq!(
config.default_output,
code2prompt_core::configuration::OutputDestination::Clipboard
);
assert_eq!(config.path, Some("./src".to_string()));
assert_eq!(config.include_patterns, vec!["*.rs", "*.toml"]);
assert_eq!(config.exclude_patterns, vec!["target", "node_modules"]);
assert!(config.line_numbers);
assert!(!config.absolute_path);
assert!(!config.full_directory_tree);
assert_eq!(config.output_format, Some(OutputFormat::Markdown));
assert_eq!(config.sort_method, Some(FileSortMethod::NameAsc));
assert_eq!(
config.encoding,
Some(code2prompt_core::tokenizer::TokenizerType::Cl100kBase)
);
assert_eq!(
config.token_format,
Some(code2prompt_core::tokenizer::TokenFormat::Format)
);
assert!(config.diff_enabled);
assert_eq!(
config.diff_branches,
Some(vec!["main".to_string(), "feature-x".to_string()])
);
assert_eq!(
config.log_branches,
Some(vec!["v1.0.0".to_string(), "v1.1.0".to_string()])
);
assert_eq!(config.template_name, Some("default".to_string()));
assert!(config.token_map_enabled);
assert_eq!(
config.user_variables.get("project"),
Some(&"code2prompt".to_string())
);
assert_eq!(
config.user_variables.get("author"),
Some(&"ODAncona".to_string())
);
}
/// Test TOML config export functionality
#[test]
fn test_toml_config_export() {
use code2prompt_core::configuration::{Code2PromptConfig, export_config_to_toml};
let config = Code2PromptConfig::builder()
.path("./test")
.include_patterns(vec!["*.rs".to_string()])
.exclude_patterns(vec!["target".to_string()])
.line_numbers(true)
.build()
.unwrap();
let toml_str = export_config_to_toml(&config).expect("Should export to TOML");
// Verify the exported TOML contains expected values
assert!(toml_str.contains("default_output = \"stdout\""));
assert!(toml_str.contains("path = \"./test\""));
assert!(toml_str.contains("include_patterns = [\"*.rs\"]"));
assert!(toml_str.contains("exclude_patterns = [\"target\"]"));
assert!(toml_str.contains("line_numbers = true"));
}
/// Test local config file loading
#[test]
fn test_local_config_file_loading() {
let temp_dir = TempDir::new().expect("Should create temp dir");
let config_path = temp_dir.path().join(".c2pconfig");
let toml_content = r#"
default_output = "stdout"
include_patterns = ["*.rs"]
line_numbers = true
"#;
fs::write(&config_path, toml_content).expect("Should write config file");
// Change to the temp directory
let original_dir = std::env::current_dir().expect("Should get current dir");
std::env::set_current_dir(temp_dir.path()).expect("Should change dir");
// Test that the config is loaded (we can't easily test the actual loading here
// without more complex setup, but we can test the file exists)
assert!(config_path.exists());
// Restore original directory
std::env::set_current_dir(original_dir).expect("Should restore dir");
}
/// Test new Unix-style default behavior (stdout)
#[test]
fn test_unix_style_default_stdout() {
let temp_dir = TempDir::new().expect("Should create temp dir");
// Create a test.py file with expected content
fs::write(temp_dir.path().join("test.py"), "print('Hello, World!')")
.expect("Should write test file");
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
let temp_path = temp_dir.path().to_path_buf();
cmd.arg(&temp_path)
.assert()
.success()
.stdout(contains("test.py"))
.stdout(contains("print('Hello, World!')"));
// Keep temp_dir alive until the end
drop(temp_dir);
}
/// Test new clipboard flag
#[test]
fn test_clipboard_flag() {
let test_env = StdoutTestEnv::new();
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(test_env.path())
.arg("-c") // New clipboard flag
.assert()
.success()
// Should not output to stdout when using clipboard
.stdout(contains("test.py").not());
}
/// Test that CLI args override config files
#[test]
fn test_cli_args_override_config() {
let temp_dir = TempDir::new().expect("Should create temp dir");
let config_path = temp_dir.path().join(".c2pconfig");
// Create a config that would normally exclude .py files
let toml_content = r#"
default_output = "clipboard"
exclude_patterns = ["*.py"]
"#;
fs::write(&config_path, toml_content).expect("Should write config file");
fs::write(temp_dir.path().join("test.py"), "print('Hello')").expect("Should write test file");
// CLI args should override config - include .py files despite config
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.current_dir(temp_dir.path())
.arg(".")
.arg("-i")
.arg("*.py") // CLI override
.arg("-O")
.arg("-") // Force output to stdout to see the result
.assert()
.success()
.stdout(contains("test.py"))
.stdout(contains("print('Hello')"));
}
/// Test configuration info messages
#[test]
fn test_config_info_messages() {
let temp_dir = TempDir::new().expect("Should create temp dir");
let config_path = temp_dir.path().join(".c2pconfig");
let toml_content = r#"
default_output = "stdout"
"#;
fs::write(&config_path, toml_content).expect("Should write config file");
fs::write(temp_dir.path().join("test.txt"), "content").expect("Should write test file");
// Run with the temp directory as argument and set current directory for the command
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.current_dir(temp_dir.path())
.arg(".")
.assert()
.success()
.stderr(contains("[i] Using config from:"));
}
/// Test default configuration message
#[test]
fn test_default_config_message() {
let temp_dir = TempDir::new().expect("Should create temp dir");
fs::write(temp_dir.path().join("test.txt"), "content").expect("Should write test file");
// Run with the temp directory as argument and set current directory for the command
// No config file exists, so it should use default configuration
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.current_dir(temp_dir.path())
.arg(".")
.assert()
.success()
.stderr(contains("[i] Using default configuration"));
}
/// Test CLI args message - now CLI args are applied on top of config
#[test]
fn test_cli_args_message() {
let test_env = StdoutTestEnv::new();
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(test_env.path())
.arg("-i")
.arg("*.py")
.assert()
.success()
.stderr(contains("[i] Using default configuration")); // Now always loads config first
}
================================================
FILE: crates/code2prompt/tests/git_integration_test.rs
================================================
//! Git integration tests for code2prompt
//!
//! This module tests git-related functionality including gitignore handling
//! and git repository integration using rstest fixtures.
mod common;
use common::fixtures::*;
use common::*;
use log::debug;
use predicates::prelude::*;
use predicates::str::contains;
use rstest::*;
/// Test gitignore functionality - files should be ignored by default
#[rstest]
fn test_gitignore(git_test_env: GitTestEnv) {
let mut cmd = git_test_env.command();
cmd.assert().success();
let output = git_test_env.read_output();
debug!("Test gitignore output:\n{}", output);
// Should include files not in gitignore
assert!(contains("included.txt").eval(&output));
assert!(contains("Included file").eval(&output));
// Should exclude files in gitignore
assert!(contains("ignored.txt").not().eval(&output));
assert!(contains("Ignored file").not().eval(&output));
}
/// Test --no-ignore flag - should include gitignored files
#[rstest]
fn test_gitignore_no_ignore(git_test_env: GitTestEnv) {
let mut cmd = git_test_env.command();
cmd.arg("--no-ignore").assert().success();
let output = git_test_env.read_output();
debug!("Test --no-ignore flag output:\n{}", output);
// Should include all files when ignoring gitignore
assert!(contains("included.txt").eval(&output));
assert!(contains("Included file").eval(&output));
assert!(contains("ignored.txt").eval(&output));
assert!(contains("Ignored file").eval(&output));
}
/// Test that git repository is properly initialized in fixture
#[rstest]
fn test_git_repo_initialization(git_test_env: GitTestEnv) {
// Verify that the git repository exists
let git_dir = git_test_env.dir.path().join(".git");
assert!(git_dir.exists(), "Git repository should be initialized");
assert!(git_dir.is_dir(), "Git directory should be a directory");
}
/// Test gitignore with different patterns
#[rstest]
#[case("*.log", "test.log", "Log file content")]
#[case("build/", "build/output.txt", "Build output")]
#[case("*.tmp", "temp.tmp", "Temporary content")]
fn test_gitignore_patterns(
#[case] pattern: &str,
#[case] file_path: &str,
#[case] file_content: &str,
) {
let env = GitTestEnv::new();
// Create the test file
create_temp_file(env.dir.path(), file_path, file_content);
// Create gitignore with the pattern
let gitignore_path = env.dir.path().join(".gitignore");
std::fs::write(&gitignore_path, pattern).expect("Failed to write gitignore");
let mut cmd = env.command();
cmd.assert().success();
let output = env.read_output();
debug!("Test gitignore pattern '{}' output:\n{}", pattern, output);
// File should be ignored
assert!(
contains(file_content).not().eval(&output),
"File with pattern '{}' should be ignored",
pattern
);
// Test with --no-ignore
let mut cmd_no_ignore = env.command();
cmd_no_ignore.arg("--no-ignore").assert().success();
let output_no_ignore = env.read_output();
assert!(
contains(file_content).eval(&output_no_ignore),
"File with pattern '{}' should be included with --no-ignore",
pattern
);
}
================================================
FILE: crates/code2prompt/tests/integration_test.rs
================================================
//! Integration tests for code2prompt file filtering functionality
//!
//! This module tests the include/exclude patterns, file filtering,
//! and directory tree generation features using rstest fixtures.
mod common;
use common::fixtures::*;
use common::*;
use log::debug;
use predicates::prelude::*;
use predicates::str::contains;
use rstest::*;
/// Test file filtering with various include/exclude patterns
#[rstest]
fn test_file_filtering(
basic_test_env: BasicTestEnv,
#[values(
("include_extensions", vec!["--include=*.py"], vec!["foo.py", "content foo.py", "FOO.py", "CONTENT FOO.PY"], vec!["content qux.txt"]),
("exclude_extensions", vec!["--exclude=*.txt"], vec!["foo.py", "content foo.py", "FOO.py", "CONTENT FOO.PY"], vec!["lowercase/qux.txt", "content qux.txt"]),
("include_files", vec!["--include=**/foo.py,**/bar.py"], vec!["foo.py", "content foo.py", "bar.py", "content bar.py"], vec!["lowercase/baz.py", "content baz.py"]),
("include_folders", vec!["--include=**/lowercase/**"], vec!["foo.py", "content foo.py", "baz.py", "content baz.py"], vec!["uppercase/FOO"]),
("exclude_files", vec!["--exclude=**/foo.py,**/bar.py"], vec!["baz.py", "content baz.py"], vec!["lowercase/foo.py", "content foo.py", "lowercase/bar.py", "content bar.py"]),
("exclude_folders", vec!["--exclude=**/uppercase/**"], vec!["foo.py", "content foo.py", "baz.py", "content baz.py"], vec!["CONTENT FOO.py"])
)]
test_case: (&str, Vec<&str>, Vec<&str>, Vec<&str>),
) {
let (name, args, should_include, should_exclude) = test_case;
let mut cmd = basic_test_env.command();
for arg in args {
cmd.arg(arg);
}
cmd.assert().success();
let output = basic_test_env.read_output();
debug!("Test {} output:\n{}", name, output);
// Check that expected content is included
for expected in should_include {
assert!(
contains(expected).eval(&output),
"Test {}: Expected '{}' to be included in output",
name,
expected
);
}
// Check that expected content is excluded
for expected in should_exclude {
assert!(
contains(expected).not().eval(&output),
"Test {}: Expected '{}' to be excluded from output",
name,
expected
);
}
}
/// Test include/exclude combination with exclude priority
#[rstest]
fn test_include_exclude_with_exclude_priority(basic_test_env: BasicTestEnv) {
let mut cmd = basic_test_env.command();
cmd.arg("--include=*.py,**/lowercase/**")
.arg("--exclude=**/foo.py,**/uppercase/**")
.assert()
.success();
let output = basic_test_env.read_output();
debug!("Test include and exclude combinations output:\n{}", output);
// Should include
assert!(contains("lowercase/baz.py").eval(&output));
assert!(contains("content baz.py").eval(&output));
// Should exclude (exclude takes priority)
assert!(contains("lowercase/foo.py").not().eval(&output));
assert!(contains("content foo.py").not().eval(&output));
assert!(contains("uppercase/FOO.py").not().eval(&output));
assert!(contains("CONTENT FOO.PY").not().eval(&output));
}
/// Test with no filters (should include everything)
#[rstest]
fn test_no_filters(basic_test_env: BasicTestEnv) {
let mut cmd = basic_test_env.command();
cmd.assert().success();
let output = basic_test_env.read_output();
debug!("Test no filters output:\n{}", output);
// Should include all files
let expected_files = vec![
"foo.py",
"content foo.py",
"baz.py",
"content baz.py",
"FOO.py",
"CONTENT FOO.PY",
"BAZ.py",
"CONTENT BAZ.PY",
];
for expected in expected_files {
assert!(
contains(expected).eval(&output),
"Expected '{}' to be included when no filters are applied",
expected
);
}
}
/// Test full directory tree generation
#[rstest]
fn test_full_directory_tree(basic_test_env: BasicTestEnv) {
let mut cmd = basic_test_env.command();
cmd.arg("--full-directory-tree")
.arg("--exclude")
.arg("**/uppercase/**")
.assert()
.success();
let output = basic_test_env.read_output();
debug!("Test full directory tree output:\n{}", output);
// Should show directory structure
assert!(contains("├── lowercase").eval(&output));
assert!(contains("└── uppercase").eval(&output));
// Should show files in tree format
assert!(contains("├── foo.py").eval(&output));
assert!(contains("├── bar.py").eval(&output));
assert!(contains("├── baz.py").eval(&output));
// Should show excluded directory structure but not content
assert!(contains("├── FOO.py").eval(&output));
assert!(contains("├── BAR.py").eval(&output));
assert!(contains("├── BAZ.py").eval(&output));
assert!(!contains("CONTENT BAR.PY").eval(&output));
}
/// Test brace expansion patterns
#[rstest]
fn test_brace_expansion(basic_test_env: BasicTestEnv) {
let mut cmd = basic_test_env.command();
cmd.arg("--include")
.arg("lowercase/{foo.py,bar.py,baz.py}")
.arg("--exclude")
.arg("lowercase/{qux.txt,corge.txt,grault.txt}")
.assert()
.success();
let output = basic_test_env.read_output();
debug!("Test brace expansion output:\n{}", output);
// Should include specified Python files
assert!(contains("foo.py").eval(&output));
assert!(contains("content foo.py").eval(&output));
assert!(contains("bar.py").eval(&output));
assert!(contains("content bar.py").eval(&output));
assert!(contains("baz.py").eval(&output));
assert!(contains("content baz.py").eval(&output));
// Should exclude specified text files
assert!(contains("qux.txt").not().eval(&output));
assert!(contains("corge.txt").not().eval(&output));
assert!(contains("grault.txt").not().eval(&output));
}
/// Test command creation helper
#[rstest]
fn test_command_helper(basic_test_env: BasicTestEnv) {
// Test that our fixture creates working commands
let mut cmd = basic_test_env.command();
cmd.assert().success();
// Verify output file was created and is readable
let output = basic_test_env.read_output();
assert!(!output.is_empty(), "Output should not be empty");
}
================================================
FILE: crates/code2prompt/tests/std_output_test.rs
================================================
//! Standard output tests for code2prompt
//!
//! This module tests stdout functionality, output redirection,
//! and various output modes using rstest fixtures.
mod common;
use common::fixtures::*;
use common::*;
use log::debug;
use predicates::prelude::*;
use predicates::str::contains;
use rstest::*;
/// ~~~ Default Output Behavior ~~~
#[rstest]
fn test_output_default(stdout_test_env: StdoutTestEnv) {
// Default behavior: output to stdout with status messages in stderr
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.assert()
.success()
// Content should be in stdout
.stdout(contains("test.py"))
.stdout(contains("print('Hello, World!')"))
// Status messages should be in stderr
.stderr(contains("Token count:"))
// Status messages should NOT be in stdout
.stdout(contains("Token count:").not());
debug!("✓ Default stdout output test passed");
}
/// ~~~ Stdout Configurations ~~~
#[rstest]
#[case("explicit_dash", vec!["-O", "-", "--no-clipboard"], vec!["test.py", "print('Hello, World!')", "README.md", "# Test Project"], vec!["✓","▹▹▹▹▸ Done!","Token count:","Copied to clipboard successfully"], true)]
#[case("long_form", vec!["--output-file", "-", "--no-clipboard"], vec!["test.py", "print('Hello, World!')", "README.md", "# Test Project"], vec!["✓","▹▹▹▹▸ Done!","Token count:","Copied to clipboard successfully"], true)]
#[case("quiet_mode", vec!["--quiet", "-O", "-", "--no-clipboard"], vec!["test.py", "print('Hello, World!')"], vec!["✓","▹▹▹▹▸ Done!","Token count:","Copied to clipboard successfully"], true)]
fn test_stdout_configurations(
stdout_test_env: StdoutTestEnv,
#[case] test_name: &str,
#[case] args: Vec<&str>,
#[case] should_contain: Vec<&str>,
#[case] should_not_contain: Vec<&str>,
#[case] should_succeed: bool,
) {
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path());
for arg in args {
cmd.arg(arg);
}
let assertion = cmd.assert();
if should_succeed {
let assertion = assertion.success();
// Check content that should be present
let mut assertion = assertion;
for content in should_contain {
assertion = assertion.stdout(contains(content));
}
// Check content that should not be present
for content in should_not_contain {
assertion = assertion.stdout(contains(content).not());
}
debug!("✓ {} test passed", test_name);
} else {
assertion.failure();
debug!("✓ {} test passed (correctly failed)", test_name);
}
}
/// ~~~ File Output Configurations ~~~
#[rstest]
#[case("file_output", vec!["--output-file", "output.txt", "--no-clipboard"], vec!["test.py", "print('Hello, World!')", "README.md"], vec![], true)]
#[case("file_output_quiet", vec!["--output-file", "output.txt", "--quiet", "--no-clipboard"], vec!["test.py", "print('Hello, World!')"], vec!["✓"], true)]
#[case("file_output_json", vec!["--output-file", "output.txt", "--output-format", "json", "--no-clipboard"], vec!["{", "\"files\"", "test.py"], vec![], true)]
#[case("file_output_xml", vec!["--output-file", "output.txt", "--output-format", "xml", "--no-clipboard"], vec!["", "", "test.py"], vec![], true)]
#[case("file_output_markdown", vec!["--output-file", "output.txt", "--output-format", "markdown", "--no-clipboard"], vec!["Source Tree:", "```", "test.py"], vec![], true)]
fn test_file_output_configurations(
stdout_test_env: StdoutTestEnv,
#[case] test_name: &str,
#[case] args: Vec<&str>,
#[case] should_contain: Vec<&str>,
#[case] should_not_contain: Vec<&str>,
#[case] should_succeed: bool,
) {
let output_file = stdout_test_env.dir.path().join("output.txt");
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path());
// Replace "output.txt" in args with the actual path
for arg in args {
if arg == "output.txt" {
cmd.arg(output_file.to_str().unwrap());
} else {
cmd.arg(arg);
}
}
let assertion = cmd.assert();
if should_succeed {
assertion.success();
// Read the output file and check its contents
let file_content =
std::fs::read_to_string(&output_file).expect("Should be able to read output file");
// Check content that should be present
for content in should_contain {
assert!(
file_content.contains(content),
"Test {}: Expected '{}' in file output",
test_name,
content
);
}
// Check content that should not be present
for content in should_not_contain {
assert!(
!file_content.contains(content),
"Test {}: Expected '{}' NOT to be in file output",
test_name,
content
);
}
debug!("✓ {} test passed", test_name);
} else {
assertion.failure();
debug!("✓ {} test passed (correctly failed)", test_name);
}
}
/// Test conflicting output options (should fail)
#[rstest]
fn test_conflicting_output_options_should_fail(stdout_test_env: StdoutTestEnv) {
// Test: Using both default stdout and explicit -O - should fail
// This is a logical conflict - you can't output to stdout in two different ways
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("-")
.arg("-O")
.arg("-")
.arg("--no-clipboard")
.assert()
.failure();
debug!("✓ Conflicting output options test passed (correctly failed)");
}
// Using both output file and stdout should fail
#[rstest]
fn test_output_file_vs_stdout_conflict(stdout_test_env: StdoutTestEnv) {
let output_file = stdout_test_env.dir.path().join("output.txt");
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("--output-file")
.arg(output_file.to_str().unwrap())
.arg("-O")
.arg("-")
.arg("--no-clipboard")
.assert()
.failure()
.stderr(
contains("cannot be used multiple times")
.or(contains("conflict"))
.or(contains("mutually exclusive")),
);
debug!("✓ Output file vs stdout conflict test passed (correctly failed)");
}
/// Test stdout with different output formats
#[rstest]
#[case("json", "{", "\"files\"")]
#[case("xml", "<", ">")]
#[case("markdown", "Source Tree:", "```")]
fn test_stdout_with_different_formats(
stdout_test_env: StdoutTestEnv,
#[case] format: &str,
#[case] expected_start: &str,
#[case] expected_content: &str,
) {
// Test: Stdout should work with different output formats
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("--output-format")
.arg(format)
.arg("-O")
.arg("-")
.arg("--no-clipboard")
.assert()
.success()
.stdout(contains(expected_start))
.stdout(contains(expected_content))
.stdout(contains("test.py"));
debug!("✓ Stdout with {} format test passed", format);
}
/// Test stderr messages in normal mode (should show status messages)
#[rstest]
fn test_stderr_messages_normal_mode(stdout_test_env: StdoutTestEnv) {
let output_file = stdout_test_env.dir.path().join("output.txt");
// Test with file output in normal mode - should show success message in stderr
// Note: In test environment, auto-quiet is enabled, so Token count might not appear
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("--output-file")
.arg(output_file.to_str().unwrap())
.arg("--no-clipboard")
.assert()
.success()
.stderr(contains("Prompt written to file:"));
debug!("✓ Normal mode stderr messages test passed");
}
/// Test stderr messages in quiet mode
#[rstest]
fn test_stderr_messages_quiet_mode(stdout_test_env: StdoutTestEnv) {
let output_file = stdout_test_env.dir.path().join("output.txt");
// Test with file output in quiet mode - should still show file write confirmation
// but suppress other messages
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("--output-file")
.arg(output_file.to_str().unwrap())
.arg("--quiet")
.arg("--no-clipboard")
.assert()
.success()
.stderr(contains("Done!").not());
// Note: Even in quiet mode, file write confirmation might still appear
// This is expected behavior for important operations
debug!("✓ Quiet mode stderr messages test passed");
}
/// Test stderr messages with clipboard operations
#[rstest]
fn test_stderr_messages_with_clipboard(stdout_test_env: StdoutTestEnv) {
// Test without --no-clipboard flag - should attempt clipboard operation
// Note: In test environment (non-terminal), auto-quiet is enabled
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path()).assert().success();
// In test environment, clipboard operations might be silent due to auto-quiet
// This is expected behavior
debug!("✓ Clipboard stderr messages test passed");
}
/// Test stderr behavior with different output formats
#[rstest]
#[case("json")]
#[case("xml")]
#[case("markdown")]
fn test_stderr_with_output_formats(stdout_test_env: StdoutTestEnv, #[case] format: &str) {
let output_file = stdout_test_env.dir.path().join("output.txt");
// Test that stderr messages appear regardless of output format
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("--output-file")
.arg(output_file.to_str().unwrap())
.arg("--output-format")
.arg(format)
.arg("--no-clipboard")
.assert()
.success()
.stderr(contains("Prompt written to file:"));
debug!("✓ Stderr with {} format test passed", format);
}
/// Test that stdout and stderr are properly separated
#[rstest]
fn test_stdout_stderr_separation(stdout_test_env: StdoutTestEnv) {
// Test that when outputting to stdout, status messages go to stderr, not stdout
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("code2prompt");
cmd.arg(stdout_test_env.path())
.arg("-O")
.arg("-")
.arg("--no-clipboard")
.assert()
.success()
// Content should be in stdout
.stdout(contains("test.py"))
.stdout(contains("print('Hello, World!')"))
// Status messages should NOT be in stdout (they go to stderr in non-quiet mode)
.stdout(contains("Token count:").not())
.stdout(contains("✓").not());
debug!("✓ Stdout/stderr separation test passed");
}
/// Test that fixture creates proper test environment
#[rstest]
fn test_stdout_fixture_setup(stdout_test_env: StdoutTestEnv) {
// Verify that the fixture created the expected files
let test_files = vec!["test.py", "README.md", "config.json"];
for file in test_files {
let file_path = stdout_test_env.dir.path().join(file);
assert!(file_path.exists(), "Test file {} should exist", file);
}
debug!("✓ Stdout fixture setup test passed");
}
================================================
FILE: crates/code2prompt/tests/template_integration_test.rs
================================================
//! Template integration tests for code2prompt
//!
//! This module tests template functionality, output formats,
//! and template rendering using rstest fixtures.
mod common;
use common::fixtures::*;
use common::*;
use log::debug;
use predicates::prelude::*;
use predicates::str::{contains, ends_with, starts_with};
use rstest::*;
/// Test different output format templates
#[rstest]
#[case("markdown", vec!["Source Tree:", "```rs", "fn main()", "Hello, world!"])]
#[case("xml", vec!["", "", ".rs\"", "fn main()", "Hello, world!"])]
fn test_output_format_templates(
template_test_env: TemplateTestEnv,
#[case] format: &str,
#[case] expected_content: Vec<&str>,
) {
let mut cmd = template_test_env.command();
cmd.arg(format!("--output-format={}", format))
.assert()
.success();
let output = template_test_env.read_output();
debug!("{} template output:\n{}", format, output);
// Check format-specific content
for expected in expected_content {
assert!(
contains(expected).eval(&output),
"Expected '{}' in {} format output",
expected,
format
);
}
}
/// Test JSON output format (special case with structured output)
#[rstest]
fn test_json_output_format(template_test_env: TemplateTestEnv) {
let mut cmd = template_test_env.command();
cmd.arg("--output-format=json").assert().success();
let output = template_test_env.read_output();
debug!("JSON output format:\n{}", output);
// JSON output should be structured
assert!(starts_with("{").eval(&output));
assert!(contains("\"directory_name\":").eval(&output));
assert!(contains("\"prompt\": \"").eval(&output));
assert!(ends_with("}").eval(&output));
}
/// Test that template fixture creates proper codebase structure
#[rstest]
fn test_template_fixture_setup(template_test_env: TemplateTestEnv) {
// Verify that the fixture created the expected code structure
let expected_files = vec![
("src/main.rs", "fn main()"),
("src/lib.rs", "pub fn add"),
("tests/test.rs", "#[test]"),
];
for (file_path, expected_content) in expected_files {
let file_path = template_test_env.dir.path().join(file_path);
assert!(
file_path.exists(),
"Test file {} should exist",
file_path.display()
);
let content =
std::fs::read_to_string(&file_path).expect("Should be able to read test file");
assert!(
content.contains(expected_content),
"File {} should contain '{}'",
file_path.display(),
expected_content
);
}
debug!("✓ Template fixture setup test passed");
}
/// Test basic template rendering with default format
#[rstest]
fn test_basic_template_rendering(template_test_env: TemplateTestEnv) {
let mut cmd = template_test_env.command();
cmd.assert().success();
let output = template_test_env.read_output();
debug!("Basic template rendering output:\n{}", output);
// Should contain code from all test files
assert!(contains("fn main()").eval(&output));
assert!(contains("Hello, world!").eval(&output));
assert!(contains("pub fn add").eval(&output));
assert!(contains("#[test]").eval(&output));
assert!(contains("assert_eq!").eval(&output));
}
/// Test template with different file extensions
#[rstest]
fn test_template_with_file_extensions(template_test_env: TemplateTestEnv) {
let mut cmd = template_test_env.command();
cmd.assert().success();
let output = template_test_env.read_output();
debug!("Template with file extensions output:\n{}", output);
// Should properly identify and format Rust files
assert!(contains("src/main.rs").eval(&output));
assert!(contains("src/lib.rs").eval(&output));
assert!(contains("tests/test.rs").eval(&output));
}
/// Test template output contains proper structure
#[rstest]
fn test_template_output_structure(template_test_env: TemplateTestEnv) {
let mut cmd = template_test_env.command();
cmd.assert().success();
let output = template_test_env.read_output();
debug!("Template output structure:\n{}", output);
// Should contain directory structure information
assert!(contains("src").eval(&output));
assert!(contains("tests").eval(&output));
// Should contain file content
assert!(!output.trim().is_empty(), "Output should not be empty");
// Should be properly formatted (not just raw concatenation)
let line_count = output.lines().count();
assert!(
line_count > 10,
"Output should have substantial content with multiple lines"
);
}
/// Test template with include/exclude filters
#[rstest]
#[case("--include=*.rs", vec!["src/main.rs", "src/lib.rs", "tests/test.rs"])]
#[case("--exclude=**/test.rs", vec!["src/main.rs", "src/lib.rs"])]
#[case("--include=src/**", vec!["src/main.rs", "src/lib.rs"])]
fn test_template_with_filters(
template_test_env: TemplateTestEnv,
#[case] filter_arg: &str,
#[case] expected_files: Vec<&str>,
) {
let mut cmd = template_test_env.command();
cmd.arg(filter_arg).assert().success();
let output = template_test_env.read_output();
debug!("Template with filter '{}' output:\n{}", filter_arg, output);
// Should contain expected files
for expected_file in expected_files {
assert!(
contains(expected_file).eval(&output),
"Expected file '{}' with filter '{}'",
expected_file,
filter_arg
);
}
}
/// Test template command creation
#[rstest]
fn test_template_command_creation(template_test_env: TemplateTestEnv) {
// Test that our fixture creates working commands
let mut cmd = template_test_env.command();
cmd.assert().success();
// Verify output file was created and is readable
let output = template_test_env.read_output();
assert!(!output.is_empty(), "Template output should not be empty");
// Verify the output file exists
assert!(
template_test_env.output_file_exists(),
"Output file should exist after command execution"
);
}
================================================
FILE: crates/code2prompt-core/Cargo.toml
================================================
[package]
name = "code2prompt_core"
version = "4.2.0"
authors = [
"Mufeed VH ",
"Olivier D'Ancona ",
]
description = "A command-line (CLI) tool to generate an LLM prompt from codebases of any size, fast."
keywords = ["code", "ingestion", "prompt", "llm", "agent"]
categories = ["command-line-utilities", "development-tools"]
homepage = "https://code2prompt.dev"
documentation = "https://code2prompt.dev/docs/welcome"
repository = "https://github.com/mufeedvh/code2prompt"
license = "MIT"
exclude = [".github/*", ".assets/*"]
edition = "2024"
readme = "../../README.md"
[features]
default = []
[dependencies]
anyhow = { workspace = true }
bracoxide = { workspace = true }
colored = { workspace = true }
content_inspector = { workspace = true }
csv = { workspace = true }
derive_builder = { workspace = true }
encoding_rs = { workspace = true }
ignore = { workspace = true }
indicatif = { workspace = true }
git2 = { workspace = true }
globset = { workspace = true }
handlebars = { workspace = true }
log = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
termtree = { workspace = true }
tiktoken-rs = { workspace = true }
toml = { workspace = true }
rayon = { workspace = true }
chardetng = { workspace = true }
[lib]
name = "code2prompt_core"
path = "src/lib.rs"
crate-type = ["rlib"]
[package.metadata.deb]
section = "utility"
assets = [["target/release/code2prompt_core", "/usr/bin/", "755"]]
[dev-dependencies]
tempfile = "3.24"
assert_cmd = "2.1.1"
predicates = "3.1"
env_logger = "0.11.3"
rstest = "0.26.1"
================================================
FILE: crates/code2prompt-core/src/builtin_templates.rs
================================================
//! Built-in templates embedded as static resources.
//!
//! This module provides access to all built-in templates that are embedded
//! directly into the binary, making them available even when the crate is
//! installed from crates.io without access to the source file structure.
use std::{collections::HashMap, sync::OnceLock};
/// Information about a built-in template
#[derive(Debug, Clone, Copy)]
pub struct BuiltinTemplate {
pub name: &'static str,
pub content: &'static str,
pub description: &'static str,
}
/// All built-in templates embedded as static strings
pub struct BuiltinTemplates;
static TEMPLATES: OnceLock> = OnceLock::new();
impl BuiltinTemplates {
/// Get all available built-in templates
pub fn get_all() -> &'static HashMap<&'static str, BuiltinTemplate> {
TEMPLATES.get_or_init(|| {
HashMap::from([
(
"default-markdown",
BuiltinTemplate {
name: "Default (Markdown)",
content: include_str!("default_template_md.hbs"),
description: "Default markdown template for code analysis",
},
),
(
"default-xml",
BuiltinTemplate {
name: "Default (XML)",
content: include_str!("default_template_xml.hbs"),
description: "Default XML template for code analysis",
},
),
(
"binary-exploitation-ctf-solver",
BuiltinTemplate {
name: "Binary Exploitation CTF Solver",
content: include_str!("../templates/binary-exploitation-ctf-solver.hbs"),
description: "Template for solving binary exploitation CTF challenges",
},
),
(
"clean-up-code",
BuiltinTemplate {
name: "Clean Up Code",
content: include_str!("../templates/clean-up-code.hbs"),
description: "Template for code cleanup and refactoring",
},
),
(
"cryptography-ctf-solver",
BuiltinTemplate {
name: "Cryptography CTF Solver",
content: include_str!("../templates/cryptography-ctf-solver.hbs"),
description: "Template for solving cryptography CTF challenges",
},
),
(
"document-the-code",
BuiltinTemplate {
name: "Document the Code",
content: include_str!("../templates/document-the-code.hbs"),
description: "Template for generating code documentation",
},
),
(
"find-security-vulnerabilities",
BuiltinTemplate {
name: "Find Security Vulnerabilities",
content: include_str!("../templates/find-security-vulnerabilities.hbs"),
description: "Template for security vulnerability analysis",
},
),
(
"fix-bugs",
BuiltinTemplate {
name: "Fix Bugs",
content: include_str!("../templates/fix-bugs.hbs"),
description: "Template for bug fixing and debugging",
},
),
(
"improve-performance",
BuiltinTemplate {
name: "Improve Performance",
content: include_str!("../templates/improve-performance.hbs"),
description: "Template for performance optimization",
},
),
(
"refactor",
BuiltinTemplate {
name: "Refactor",
content: include_str!("../templates/refactor.hbs"),
description: "Template for code refactoring",
},
),
(
"reverse-engineering-ctf-solver",
BuiltinTemplate {
name: "Reverse Engineering CTF Solver",
content: include_str!("../templates/reverse-engineering-ctf-solver.hbs"),
description: "Template for solving reverse engineering CTF challenges",
},
),
(
"web-ctf-solver",
BuiltinTemplate {
name: "Web CTF Solver",
content: include_str!("../templates/web-ctf-solver.hbs"),
description: "Template for solving web CTF challenges",
},
),
(
"write-git-commit",
BuiltinTemplate {
name: "Write Git Commit",
content: include_str!("../templates/write-git-commit.hbs"),
description: "Template for generating git commit messages",
},
),
(
"write-github-pull-request",
BuiltinTemplate {
name: "Write GitHub Pull Request",
content: include_str!("../templates/write-github-pull-request.hbs"),
description: "Template for generating GitHub pull request descriptions",
},
),
(
"write-github-readme",
BuiltinTemplate {
name: "Write GitHub README",
content: include_str!("../templates/write-github-readme.hbs"),
description: "Template for generating GitHub README files",
},
),
])
})
}
/// Get a specific template by its key
pub fn get_template(key: &str) -> Option {
Self::get_all().get(key).cloned()
}
/// Get all template keys
pub fn get_template_keys() -> Vec<&'static str> {
Self::get_all().keys().copied().collect()
}
/// Check if a template exists
pub fn has_template(key: &str) -> bool {
Self::get_all().contains_key(key)
}
}
================================================
FILE: crates/code2prompt-core/src/configuration.rs
================================================
//! This module defines the `Code2PromptConfig` struct and its Builder for configuring the behavior
//! of code2prompt in a stateless manner. It includes all parameters needed for file traversal,
//! code filtering, token counting, and more.
use crate::template::OutputFormat;
use crate::tokenizer::TokenizerType;
use crate::{sort::FileSortMethod, tokenizer::TokenFormat};
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// A stateless configuration object describing all the preferences and filters
/// applied when generating a code prompt. It does not store any mutable data,
/// so it can be cloned freely or shared across multiple sessions.
#[derive(Debug, Clone, Default, Builder)]
#[builder(setter(into), default)]
pub struct Code2PromptConfig {
/// Path to the root directory of the codebase.
pub path: PathBuf,
/// List of glob-like patterns to include.
pub include_patterns: Vec,
/// List of glob-like patterns to exclude.
pub exclude_patterns: Vec,
/// If true, code lines will be numbered in the output.
pub line_numbers: bool,
/// If true, paths in the output will be absolute instead of relative.
pub absolute_path: bool,
/// If true, code2prompt will generate a full directory tree, ignoring include/exclude rules.
pub full_directory_tree: bool,
/// If true, code blocks will not be wrapped in Markdown fences (```).
pub no_codeblock: bool,
/// If true, symbolic links will be followed during traversal.
pub follow_symlinks: bool,
/// If true, hidden files and directories will be included.
pub hidden: bool,
/// If true, .gitignore rules will be ignored.
pub no_ignore: bool,
/// Defines the sorting method for files.
pub sort_method: Option,
/// Determines the output format of the final prompt.
pub output_format: OutputFormat,
/// An optional custom Handlebars template string.
pub custom_template: Option,
/// The tokenizer encoding to use for counting tokens.
pub encoding: TokenizerType,
/// The counting format to use for token counting.
pub token_format: TokenFormat,
/// If true, the git diff between HEAD and index will be included.
pub diff_enabled: bool,
/// If set, contains two branch names for which code2prompt will generate a git diff.
pub diff_branches: Option<(String, String)>,
/// If set, contains two branch names for which code2prompt will retrieve the git log.
pub log_branches: Option<(String, String)>,
/// The name of the template used.
pub template_name: String,
/// The template string itself.
pub template_str: String,
/// Extra template data
pub user_variables: HashMap,
/// If true, detailed token map breakdown will be displayed in output.
///
/// Note: Token counting always happens internally for performance optimization
/// (parallelized during file I/O). This flag only controls whether the breakdown
/// is shown to users in the final output.
pub token_map_enabled: bool,
/// If true, starts with all files deselected.
pub deselected: bool,
}
impl Code2PromptConfig {
pub fn builder() -> Code2PromptConfigBuilder {
Code2PromptConfigBuilder::default()
}
}
/// Output destination for code2prompt
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputDestination {
#[default]
Stdout,
Clipboard,
File,
}
/// TOML configuration structure that can be serialized/deserialized
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TomlConfig {
/// Default output behavior: "stdout", "clipboard", or "file"
pub default_output: OutputDestination,
/// Path to the codebase directory
pub path: Option,
/// Patterns to include
pub include_patterns: Vec,
/// Patterns to exclude
pub exclude_patterns: Vec,
/// Display options
pub line_numbers: bool,
pub absolute_path: bool,
pub full_directory_tree: bool,
/// Output format
pub output_format: Option,
/// Sort method
pub sort_method: Option,
/// Tokenizer settings
pub encoding: Option,
pub token_format: Option,
/// Git settings
pub diff_enabled: bool,
pub diff_branches: Option>,
pub log_branches: Option>,
/// Template settings
pub template_name: Option,
pub template_str: Option,
/// User variables
pub user_variables: HashMap,
/// Token map
pub token_map_enabled: bool,
/// Initial selection state
pub deselected: bool,
}
impl TomlConfig {
/// Load TOML configuration from a string
pub fn from_toml_str(content: &str) -> Result {
toml::from_str(content)
}
/// Convert TOML configuration to string
pub fn to_string(&self) -> Result {
toml::to_string_pretty(self)
}
/// Convert TomlConfig to Code2PromptConfig
pub fn to_code2prompt_config(&self) -> Code2PromptConfig {
let mut builder = Code2PromptConfig::builder();
if let Some(path) = &self.path {
builder.path(PathBuf::from(path));
}
builder
.include_patterns(self.include_patterns.clone())
.exclude_patterns(self.exclude_patterns.clone())
.line_numbers(self.line_numbers)
.absolute_path(self.absolute_path)
.full_directory_tree(self.full_directory_tree);
builder.output_format(self.output_format.unwrap_or_default());
builder.sort_method(self.sort_method);
builder.encoding(self.encoding.unwrap_or_default());
builder.token_format(self.token_format.unwrap_or_default());
builder.diff_enabled(self.diff_enabled);
if let Some(diff_branches) = &self.diff_branches
&& diff_branches.len() == 2
{
builder.diff_branches(Some((diff_branches[0].clone(), diff_branches[1].clone())));
}
if let Some(log_branches) = &self.log_branches
&& log_branches.len() == 2
{
builder.log_branches(Some((log_branches[0].clone(), log_branches[1].clone())));
}
if let Some(template_name) = &self.template_name {
builder.template_name(template_name.clone());
}
if let Some(template_str) = &self.template_str {
builder.template_str(template_str.clone());
}
builder
.user_variables(self.user_variables.clone())
.token_map_enabled(self.token_map_enabled)
.deselected(self.deselected);
builder.build().unwrap_or_default()
}
}
/// Export a Code2PromptConfig to TOML format
pub fn export_config_to_toml(config: &Code2PromptConfig) -> Result {
let toml_config = TomlConfig {
default_output: OutputDestination::Stdout, // Default for new behavior
path: Some(config.path.to_string_lossy().to_string()),
include_patterns: config.include_patterns.clone(),
exclude_patterns: config.exclude_patterns.clone(),
line_numbers: config.line_numbers,
absolute_path: config.absolute_path,
full_directory_tree: config.full_directory_tree,
output_format: Some(config.output_format),
sort_method: config.sort_method,
encoding: Some(config.encoding),
token_format: Some(config.token_format),
diff_enabled: config.diff_enabled,
diff_branches: config
.diff_branches
.as_ref()
.map(|(a, b)| vec![a.clone(), b.clone()]),
log_branches: config
.log_branches
.as_ref()
.map(|(a, b)| vec![a.clone(), b.clone()]),
template_name: if config.template_name.is_empty() {
None
} else {
Some(config.template_name.clone())
},
template_str: if config.template_str.is_empty() {
None
} else {
Some(config.template_str.clone())
},
user_variables: config.user_variables.clone(),
token_map_enabled: config.token_map_enabled,
deselected: config.deselected,
};
toml_config.to_string()
}
================================================
FILE: crates/code2prompt-core/src/default_template_md.hbs
================================================
Project Path: {{ absolute_code_path }}
Source Tree:
```txt
{{ source_tree }}
```
{{#each files}}
{{#if code}}
`{{path}}`:
{{code}}
{{/if}}
{{/each}}
{{#if git_diff}}
Git Diff:
{{ git_diff }}
{{/if}}
================================================
FILE: crates/code2prompt-core/src/default_template_xml.hbs
================================================
{{absolute_code_path}}
{{source_tree}}
{{#each files}}
{{#if code}}
{{code}}
{{/if}}
{{/each}}
{{#if git_diff}}
{{git_diff}}
{{/if}}
================================================
FILE: crates/code2prompt-core/src/file_processor/csv.rs
================================================
//! CSV file processor with schema extraction.
//!
//! This processor uses the `csv` crate to robustly parse CSV files and extract:
//! - Column headers
//! - One sample data row
//!
//! This provides sufficient context for LLMs to understand the data structure
//! without wasting tokens on thousands of rows.
use super::{DefaultTextProcessor, FileProcessor};
use anyhow::{Context, Result};
use std::path::Path;
/// CSV processor that extracts headers and one sample row.
///
/// Uses streaming to avoid loading large files into memory.
/// Falls back to raw text if parsing fails.
pub struct CsvProcessor;
impl CsvProcessor {
/// Internal processing with specific delimiter.
///
/// # Arguments
///
/// * `content` - Raw CSV bytes
/// * `delimiter` - Field delimiter (b',' for CSV, b'\t' for TSV)
/// * `path` - File path for error messages
pub(crate) fn process_with_delimiter(
&self,
content: &[u8],
delimiter: u8,
_path: &Path,
) -> Result {
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimiter)
.flexible(true) // Allow variable number of fields
.from_reader(content);
// Extract headers
let headers = reader
.headers()
.context("Failed to read CSV headers")?
.iter()
.map(|s| s.to_string())
.collect::>();
if headers.is_empty() {
anyhow::bail!("CSV file has no headers");
}
// Read first data row
let mut records = reader.records();
let first_row = records
.next()
.transpose()
.context("Failed to read first data row")?;
let mut output = String::new();
output.push_str("CSV Schema (1 sample row):\n");
output.push_str(&format!("Headers: {}\n", headers.join(", ")));
if let Some(row) = first_row {
let values: Vec = row.iter().map(|field| format!("\"{}\"", field)).collect();
output.push_str(&format!("Sample: {}\n", values.join(", ")));
// Count remaining rows for truncation message
let remaining_rows = records.count();
if remaining_rows > 0 {
output.push_str(&format!("... [{} more rows omitted]\n", remaining_rows));
}
} else {
output.push_str("(No data rows found)\n");
}
Ok(output)
}
}
impl FileProcessor for CsvProcessor {
fn process(&self, content: &[u8], path: &Path) -> Result {
match self.process_with_delimiter(content, b',', path) {
Ok(result) => Ok(result),
Err(e) => {
log::warn!(
"CSV parsing failed for {:?}: {}. Using raw text fallback.",
path,
e
);
// Fallback to raw text
let fallback = DefaultTextProcessor;
fallback.process(content, path)
}
}
}
}
================================================
FILE: crates/code2prompt-core/src/file_processor/default.rs
================================================
//! Default text processor for standard file types.
//!
//! This processor handles all file types that don't require special processing.
//! It converts raw bytes to UTF-8 strings using lossy conversion to handle
//! invalid UTF-8 sequences gracefully.
use super::FileProcessor;
use anyhow::Result;
use chardetng::EncodingDetector;
use std::path::Path;
/// Default processor that converts bytes to UTF-8 string.
///
/// This processor uses the `chardetng` crate to detect the encoding of the input bytes
/// and converts them to a UTF-8 string. If the encoding cannot be determined, it
/// defaults to UTF-8. Invalid sequences are replaced with the Unicode replacement character.
pub struct DefaultTextProcessor;
impl FileProcessor for DefaultTextProcessor {
fn process(&self, content: &[u8], _path: &Path) -> Result {
let mut detector = EncodingDetector::new();
detector.feed(content, true);
// Guess the encoding; if none is found, default to UTF-8
let encoding = detector.guess(None, true);
let (cow, _encoding_used, _had_errors) = encoding.decode(content);
match cow {
std::borrow::Cow::Owned(s) => Ok(s),
std::borrow::Cow::Borrowed(s) => Ok(s.to_string()),
}
}
}
================================================
FILE: crates/code2prompt-core/src/file_processor/ipynb.rs
================================================
//! Jupyter Notebook (.ipynb) file processor.
//!
//! This processor parses Jupyter notebook JSON and extracts:
//! - Total number of cells and their types
//! - Code cells only (ignoring markdown and raw cells)
//! - First 2-3 code cells as samples
//!
//! This provides LLMs with notebook structure context without overwhelming them with all cells.
use super::{DefaultTextProcessor, FileProcessor};
use anyhow::{Context, Result};
use serde_json::Value;
use std::path::Path;
/// Jupyter Notebook processor that extracts code cells and metadata.
pub struct JupyterNotebookProcessor;
impl FileProcessor for JupyterNotebookProcessor {
fn process(&self, content: &[u8], _path: &Path) -> Result {
// Parse notebook JSON
let notebook: Value =
serde_json::from_slice(content).context("Failed to parse .ipynb file as JSON")?;
// Extract cells array
let cells = notebook
.get("cells")
.and_then(|v| v.as_array())
.context("Notebook has no 'cells' array")?;
// Count cell types
let mut code_cells = Vec::new();
let mut markdown_count = 0;
let mut raw_count = 0;
for cell in cells {
let cell_type = cell
.get("cell_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
match cell_type {
"code" => code_cells.push(cell),
"markdown" => markdown_count += 1,
"raw" => raw_count += 1,
_ => {}
}
}
let total_cells = cells.len();
// Format output
let mut output = String::new();
output.push_str("Jupyter Notebook Summary:\n");
output.push_str(&format!(
"Total cells: {} ({} code, {} markdown, {} raw)\n\n",
total_cells,
code_cells.len(),
markdown_count,
raw_count
));
if code_cells.is_empty() {
output.push_str("(No code cells found)\n");
return Ok(output);
}
// Show first 2-3 code cells
let max_cells_to_show = 3.min(code_cells.len());
for (idx, cell) in code_cells.iter().take(max_cells_to_show).enumerate() {
output.push_str(&format!("Code Cell #{}:\n", idx + 1));
// Extract source code
if let Some(source) = cell.get("source") {
let code = match source {
Value::String(s) => s.clone(),
Value::Array(arr) => {
// Join array of strings
arr.iter()
.filter_map(|v| v.as_str())
.collect::>()
.join("")
}
_ => String::from("(Unable to extract source)"),
};
output.push_str("```python\n");
output.push_str(&code);
if !code.ends_with('\n') {
output.push('\n');
}
output.push_str("```\n\n");
}
}
if code_cells.len() > max_cells_to_show {
output.push_str(&format!(
"... [{} more code cells omitted]\n",
code_cells.len() - max_cells_to_show
));
}
Ok(output)
}
}
impl JupyterNotebookProcessor {
/// Process with fallback to raw text on error.
pub fn process_with_fallback(&self, content: &[u8], path: &Path) -> Result {
match self.process(content, path) {
Ok(result) => Ok(result),
Err(e) => {
log::warn!(
"Jupyter notebook parsing failed for {:?}: {}. Using raw text fallback.",
path,
e
);
let fallback = DefaultTextProcessor;
fallback.process(content, path)
}
}
}
}
================================================
FILE: crates/code2prompt-core/src/file_processor/jsonl.rs
================================================
//! JSON Lines (JSONL) file processor with schema extraction.
//!
//! This processor parses JSONL/NDJSON files and extracts:
//! - Field names from the first JSON object
//! - One sample JSON object
//!
//! This provides sufficient context for LLMs without including thousands of lines.
use super::{DefaultTextProcessor, FileProcessor};
use anyhow::{Context, Result};
use serde_json::Value;
use std::path::Path;
/// JSONL processor that extracts schema and one sample line.
pub struct JsonLinesProcessor;
impl FileProcessor for JsonLinesProcessor {
fn process(&self, content: &[u8], _path: &Path) -> Result {
let text = String::from_utf8_lossy(content);
let mut lines = text.lines();
// Get first line
let first_line = match lines.next() {
Some(line) if !line.trim().is_empty() => line,
_ => {
anyhow::bail!("JSONL file is empty or has no valid lines");
}
};
// Parse first line as JSON
let json_obj: Value = serde_json::from_str(first_line)
.with_context(|| format!("Failed to parse first line as JSON: {}", first_line))?;
// Extract field names
let fields = if let Value::Object(map) = &json_obj {
map.keys().cloned().collect::>()
} else {
anyhow::bail!("First line is not a JSON object");
};
if fields.is_empty() {
anyhow::bail!("JSON object has no fields");
}
// Count remaining lines
let remaining_lines = lines.filter(|line| !line.trim().is_empty()).count();
// Format output
let mut output = String::new();
output.push_str("JSONL Schema (1 sample line):\n");
output.push_str(&format!("Fields: {}\n", fields.join(", ")));
output.push_str(&format!("Sample: {}\n", first_line));
if remaining_lines > 0 {
output.push_str(&format!("... [{} more lines omitted]\n", remaining_lines));
}
Ok(output)
}
}
impl JsonLinesProcessor {
/// Process with fallback to raw text on error.
pub fn process_with_fallback(&self, content: &[u8], path: &Path) -> Result {
match self.process(content, path) {
Ok(result) => Ok(result),
Err(e) => {
log::warn!(
"JSONL parsing failed for {:?}: {}. Using raw text fallback.",
path,
e
);
let fallback = DefaultTextProcessor;
fallback.process(content, path)
}
}
}
}
================================================
FILE: crates/code2prompt-core/src/file_processor/mod.rs
================================================
//! File processor module for handling different file types intelligently.
//!
//! This module provides a strategy pattern for processing file contents based on their extension
//! in order to optimize for LLM token usage. The main idea is to extract the schema rather than
//! raw data where applicable. (e.g., schema + sample for CSV, code cells for Jupyter notebooks).
use anyhow::Result;
use std::path::Path;
mod csv;
mod default;
mod ipynb;
mod jsonl;
mod tsv;
pub use csv::CsvProcessor;
pub use default::DefaultTextProcessor;
pub use ipynb::JupyterNotebookProcessor;
pub use jsonl::JsonLinesProcessor;
pub use tsv::TsvProcessor;
/// Trait for processing file contents into LLM-optimized string representations.
///
/// Each processor takes raw bytes and produces a formatted string suitable for
/// inclusion in an LLM prompt. Processors may extract schemas, truncate content,
/// or apply other transformations to reduce token usage while preserving semantic value.
pub trait FileProcessor: Send + Sync {
/// Process file content and return a formatted string.
///
/// # Arguments
///
/// * `content` - Raw file bytes
/// * `path` - File path for context and error messages
///
/// # Returns
///
/// * `Result` - Processed content or error
fn process(&self, content: &[u8], path: &Path) -> Result;
}
/// Factory function to get the appropriate processor for a file extension.
///
/// # Arguments
///
/// * `extension` - File extension (without dot)
///
/// # Returns
///
/// * `Box` - Processor instance for the given extension
///
/// # Examples
///
/// ```ignore
/// let processor = get_processor_for_extension("csv");
/// let result = processor.process(&bytes, path)?;
/// ```
pub fn get_processor_for_extension(extension: &str) -> Box {
match extension.to_lowercase().as_str() {
"csv" => Box::new(CsvProcessor),
"tsv" => Box::new(TsvProcessor),
"jsonl" | "ndjson" => Box::new(JsonLinesProcessor),
"ipynb" => Box::new(JupyterNotebookProcessor),
// Future processors can be added here:
// "parquet" => Box::new(ParquetProcessor),
// "xml" => Box::new(XmlProcessor),
_ => Box::new(DefaultTextProcessor),
}
}
================================================
FILE: crates/code2prompt-core/src/file_processor/tsv.rs
================================================
//! TSV (Tab-Separated Values) file processor.
//!
//! This processor is a thin wrapper around the CSV processor with tab delimiter.
//! It extracts headers and one sample row from TSV files.
use super::{CsvProcessor, FileProcessor};
use anyhow::Result;
use std::path::Path;
/// TSV processor that reuses CSV logic with tab delimiter.
pub struct TsvProcessor;
impl FileProcessor for TsvProcessor {
fn process(&self, content: &[u8], path: &Path) -> Result {
let csv_processor = CsvProcessor;
match csv_processor.process_with_delimiter(content, b'\t', path) {
Ok(mut result) => {
// Replace "CSV" with "TSV" in the output
result = result.replace("CSV Schema", "TSV Schema");
Ok(result)
}
Err(e) => {
log::warn!(
"TSV parsing failed for {:?}: {}. Using raw text fallback.",
path,
e
);
// Fallback to raw text
let fallback = super::DefaultTextProcessor;
fallback.process(content, path)
}
}
}
}
================================================
FILE: crates/code2prompt-core/src/filter.rs
================================================
//! This module contains pure filtering logic for files based on glob patterns.
//!
//! This module provides reusable, stateless functions for pattern matching and file filtering.
use bracoxide::explode;
use colored::*;
use globset::{Glob, GlobSet, GlobSetBuilder};
use log::{debug, warn};
use std::path::Path;
/// FilterEngine encapsulates pattern-based file filtering logic.
/// This handles the base patterns (A, B in the A,A',B,B' system).
#[derive(Debug, Clone)]
pub struct FilterEngine {
include_globset: GlobSet,
exclude_globset: GlobSet,
}
impl FilterEngine {
/// Create a new FilterEngine with the given patterns
pub fn new(include_patterns: &[String], exclude_patterns: &[String]) -> Self {
Self {
include_globset: build_globset(include_patterns),
exclude_globset: build_globset(exclude_patterns),
}
}
/// Check if a file matches the base patterns (A, B logic)
pub fn matches_patterns(&self, path: &Path) -> bool {
should_include_file(path, &self.include_globset, &self.exclude_globset)
}
/// Get access to the include globset (for advanced usage)
pub fn include_globset(&self) -> &GlobSet {
&self.include_globset
}
/// Get access to the exclude globset (for advanced usage)
pub fn exclude_globset(&self) -> &GlobSet {
&self.exclude_globset
}
/// Check if there are any include patterns
pub fn has_include_patterns(&self) -> bool {
!self.include_globset.is_empty()
}
/// Check if a file is excluded by exclude patterns
pub fn is_excluded(&self, path: &Path) -> bool {
self.exclude_globset.is_match(path)
}
}
/// Constructs a `GlobSet` from a list of glob patterns.
///
/// This function takes a slice of `String` patterns, attempts to convert each
/// pattern into a `Glob`, and adds it to a `GlobSetBuilder`. If any pattern is
/// invalid, it is ignored. The function then builds and returns a `GlobSet`.
///
/// # Arguments
///
/// * `patterns` - A slice of `String` containing glob patterns.
///
/// # Returns
///
/// * A `globset::GlobSet` containing all valid glob patterns from the input.
pub fn build_globset(patterns: &[String]) -> GlobSet {
let mut builder = GlobSetBuilder::new();
let mut expanded_patterns = Vec::new();
for pattern in patterns {
if pattern.contains('{') {
match explode(pattern) {
Ok(exp) => expanded_patterns.extend(exp),
Err(e) => warn!("⚠️ Invalid brace pattern '{}': {:?}", pattern, e),
}
} else {
expanded_patterns.push(pattern.clone());
}
}
for pattern in expanded_patterns {
// If the pattern does not contain a '/' or the platform's separator, prepend "**/"
let normalized_pattern = if pattern.contains('/') {
pattern.trim_start_matches("./").to_string()
} else {
format!("**/{}", pattern.trim_start_matches("./"))
};
match Glob::new(&normalized_pattern) {
Ok(glob) => {
builder.add(glob);
debug!("✅ Glob pattern added: '{}'", normalized_pattern);
}
Err(_) => {
warn!("⚠️ Invalid pattern: '{}'", normalized_pattern);
}
}
}
match builder.build() {
Ok(set) => set,
Err(e) => {
warn!("❌ Failed to build GlobSet: {e}");
GlobSetBuilder::new()
.build()
.expect("empty GlobSet never fails")
}
}
}
/// Determines whether a file should be included based on the provided glob patterns.
///
/// Note: The `path` argument must be a relative path (i.e. relative to the base directory)
/// for the patterns to match as expected. Absolute paths will not yield correct matching.
///
/// # Arguments
///
/// * `path` - A relative path to the file that will be checked against the patterns.
/// * `include_globset` - A GlobSet specifying which files to include.
/// If empty, all files are considered included unless excluded.
/// * `exclude_globset` - A GlobSet specifying which files to exclude.
///
/// # Returns
///
/// * `bool` - Returns `true` if the file should be included; otherwise, returns `false`.
///
/// # Behavior
///
/// When both include and exclude patterns match, exclude patterns take precedence.
pub fn should_include_file(
path: &Path,
include_globset: &GlobSet,
exclude_globset: &GlobSet,
) -> bool {
// ~~~ Matching ~~~
let included = include_globset.is_match(path);
let excluded = exclude_globset.is_match(path);
// ~~~ Decision ~~~
let result = match (included, excluded) {
(true, true) => false, // If both match, exclude takes precedence
(true, false) => true, // If only included, include it
(false, true) => false, // If only excluded, exclude it
(false, false) => include_globset.is_empty(), // If no include patterns, include everything
};
debug!(
"Result: {}, {}: {}, {}: {}, Path: {:?}",
result,
"included".bold().green(),
included,
"excluded".bold().red(),
excluded,
path.display()
);
result
}
================================================
FILE: crates/code2prompt-core/src/git.rs
================================================
//! This module handles git operations.
use anyhow::{Context, Result};
use git2::{DiffOptions, Repository};
use log::info;
use std::path::Path;
/// Generates a git diff for the repository at the provided path.
///
/// This function compares the repository's HEAD tree with the index to produce a diff of staged changes.
/// It also checks for unstaged changes (differences between the index and the working directory) and,
/// if found, appends a notification to the output.
///
/// If there are no staged changes, the function returns a message in the format:
/// `"no diff between HEAD and index"`.
///
/// # Arguments
///
/// * `repo_path` - A reference to the path of the git repository.
///
/// # Returns
///
/// * `Result` - On success, returns either the diff (with an appended note if unstaged changes exist)
/// or a message indicating that there is no diff between the compared git objects.
/// In case of error, returns an appropriate error.
pub fn get_git_diff(repo_path: &Path) -> Result {
info!("Opening repository at path: {:?}", repo_path);
let repo = Repository::open(repo_path).context("Failed to open repository")?;
let head = repo.head().context("Failed to get repository head")?;
let head_tree = head.peel_to_tree().context("Failed to peel to tree")?;
// Generate diff for staged changes (HEAD vs. index)
let staged_diff = repo
.diff_tree_to_index(
Some(&head_tree),
None,
Some(DiffOptions::new().ignore_whitespace(true)),
)
.context("Failed to generate diff for staged changes")?;
let mut staged_diff_text = Vec::new();
staged_diff
.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
staged_diff_text.extend_from_slice(line.content());
true
})
.context("Failed to print staged diff")?;
let staged_diff_output = String::from_utf8_lossy(&staged_diff_text).into_owned();
// If there is no staged diff, return a message indicating so.
if staged_diff_output.trim().is_empty() {
return Ok("no diff between HEAD and index".to_string());
}
// Generate diff for unstaged changes (index vs. working directory)
let unstaged_diff = repo
.diff_index_to_workdir(None, Some(DiffOptions::new().ignore_whitespace(true)))
.context("Failed to generate diff for unstaged changes")?;
let mut unstaged_diff_text = Vec::new();
unstaged_diff
.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
unstaged_diff_text.extend_from_slice(line.content());
true
})
.context("Failed to print unstaged diff")?;
let unstaged_diff_output = String::from_utf8_lossy(&unstaged_diff_text).into_owned();
let mut output = staged_diff_output;
if !unstaged_diff_output.trim().is_empty() {
output.push_str("\nNote: Some changes are not staged.");
}
info!("Generated git diff successfully");
Ok(output)
}
/// Generates a git diff between two branches for the repository at the provided path
///
/// # Arguments
///
/// * `repo_path` - A reference to the path of the git repository
/// * `branch1` - The name of the first branch
/// * `branch2` - The name of the second branch
///
/// # Returns
///
/// * `Result` - The generated git diff as a string or an error
pub fn get_git_diff_between_branches(
repo_path: &Path,
branch1: &str,
branch2: &str,
) -> Result {
info!("Opening repository at path: {:?}", repo_path);
let repo = Repository::open(repo_path).context("Failed to open repository")?;
for branch in [branch1, branch2].iter() {
if !branch_exists(&repo, branch) {
return Err(anyhow::anyhow!("Branch {} doesn't exist!", branch));
}
}
let branch1_commit = repo.revparse_single(branch1)?.peel_to_commit()?;
let branch2_commit = repo.revparse_single(branch2)?.peel_to_commit()?;
let branch1_tree = branch1_commit.tree()?;
let branch2_tree = branch2_commit.tree()?;
let diff = repo
.diff_tree_to_tree(
Some(&branch1_tree),
Some(&branch2_tree),
Some(DiffOptions::new().ignore_whitespace(true)),
)
.context("Failed to generate diff between branches")?;
let mut diff_text = Vec::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
diff_text.extend_from_slice(line.content());
true
})
.context("Failed to print diff")?;
info!("Generated git diff between branches successfully");
Ok(String::from_utf8_lossy(&diff_text).into_owned())
}
/// Retrieves the git log between two branches for the repository at the provided path
///
/// # Arguments
///
/// * `repo_path` - A reference to the path of the git repository
/// * `branch1` - The name of the first branch (e.g., "master")
/// * `branch2` - The name of the second branch (e.g., "migrate-manifest-v3")
///
/// # Returns
///
/// * `Result` - The git log as a string or an error
pub fn get_git_log(repo_path: &Path, branch1: &str, branch2: &str) -> Result {
info!("Opening repository at path: {:?}", repo_path);
let repo = Repository::open(repo_path).context("Failed to open repository")?;
for branch in [branch1, branch2].iter() {
if !branch_exists(&repo, branch) {
return Err(anyhow::anyhow!("Branch {} doesn't exist!", branch));
}
}
let branch1_commit = repo.revparse_single(branch1)?.peel_to_commit()?;
let branch2_commit = repo.revparse_single(branch2)?.peel_to_commit()?;
let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
revwalk
.push(branch2_commit.id())
.context("Failed to push branch2 commit to revwalk")?;
revwalk
.hide(branch1_commit.id())
.context("Failed to hide branch1 commit from revwalk")?;
revwalk.set_sorting(git2::Sort::REVERSE)?;
let mut log_text = String::new();
for oid in revwalk {
let oid = oid.context("Failed to get OID from revwalk")?;
let commit = repo.find_commit(oid).context("Failed to find commit")?;
log_text.push_str(&format!(
"{} - {}\n",
&commit.id().to_string()[..7],
commit.summary().unwrap_or("No commit message")
));
}
info!("Retrieved git log successfully");
Ok(log_text)
}
/// Checks if a git reference exists in the given repository
///
/// This function can validate any git reference including:
/// - Local and remote branch names
/// - Commit hashes (full or abbreviated)
/// - Tags
/// - Any reference that git rev-parse can resolve
///
/// # Arguments
///
/// * `repo` - A reference to the `Repository` where the reference should be checked
/// * `branch_name` - A string slice that holds the name of the reference to check
///
/// # Returns
///
/// * `bool` - `true` if the reference exists, `false` otherwise
fn branch_exists(repo: &Repository, branch_name: &str) -> bool {
repo.revparse_single(branch_name).is_ok()
}
================================================
FILE: crates/code2prompt-core/src/lib.rs
================================================
//! Core library for code2prompt.
pub mod builtin_templates;
pub mod configuration;
pub mod file_processor;
pub mod filter;
pub mod git;
pub mod path;
pub mod selection;
pub mod session;
pub mod sort;
pub mod template;
pub mod tokenizer;
pub mod util;
================================================
FILE: crates/code2prompt-core/src/path.rs
================================================
//! This module contains the functions for traversing the directory and processing the files.
use crate::configuration::Code2PromptConfig;
use crate::file_processor;
use crate::filter::{build_globset, should_include_file};
use crate::sort::{FileSortMethod, sort_files, sort_tree};
use crate::tokenizer::count_tokens;
use crate::util::strip_utf8_bom;
use anyhow::Result;
use content_inspector::{ContentType, inspect};
use ignore::WalkBuilder;
use log::debug;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use termtree::Tree;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct EntryMetadata {
pub is_dir: bool,
pub is_symlink: bool,
}
impl From<&std::fs::Metadata> for EntryMetadata {
fn from(meta: &std::fs::Metadata) -> Self {
Self {
is_dir: meta.is_dir(),
is_symlink: meta.is_symlink(),
}
}
}
/// Represents a file entry with all its metadata and content
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
pub extension: String,
pub code: String,
pub token_count: usize,
pub metadata: EntryMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub mod_time: Option,
}
/// Represents a file that needs to be processed
#[derive(Debug, Clone)]
struct FileToProcess {
/// Absolute path to the file
absolute_path: PathBuf,
/// Relative path from the root
relative_path: PathBuf,
/// File metadata
metadata: std::fs::Metadata,
}
/// Traverses the directory and returns the string representation of the tree and the vector of file entries.
///
/// This function uses the provided configuration to determine which files to include, how to format them,
/// and how to structure the directory tree.
///
/// # Arguments
///
/// * `config` - Configuration object containing path, include/exclude patterns, and other settings
/// * `selection_engine` - Optional SelectionEngine for advanced file selection with user actions
///
/// # Returns
///
/// * `Result<(String, Vec)>` - A tuple containing the string representation of the directory
/// tree and a vector of file entries
pub fn traverse_directory(
config: &Code2PromptConfig,
selection_engine: Option<&mut crate::selection::SelectionEngine>,
) -> Result<(String, Vec)> {
// Phase 1: Discovery - Build tree and collect files to process
let (tree, files_to_process) = discover_files(config, selection_engine)?;
// Phase 2: Processing - Process files in parallel
let mut files = process_files_parallel(files_to_process, config)?;
// Phase 3: Assembly - Sort and return results
assemble_results(tree, &mut files, config)
}
/// Phase 1: Discovery - Walk directories, build tree, and collect files that need processing
///
/// This phase is sequential because:
/// - Directory walking is already optimized
/// - Tree building needs sequential structure
/// - Selection engine has caching that would need synchronization
fn discover_files(
config: &Code2PromptConfig,
mut selection_engine: Option<&mut crate::selection::SelectionEngine>,
) -> Result<(Tree, Vec)> {
let canonical_root_path = config.path.canonicalize()?;
let parent_directory = display_name(&canonical_root_path);
let include_globset = build_globset(&config.include_patterns);
let exclude_globset = build_globset(&config.exclude_patterns);
// Build the Walker
let walker = WalkBuilder::new(&canonical_root_path)
.hidden(!config.hidden)
.git_ignore(!config.no_ignore)
.follow_links(config.follow_symlinks)
.build()
.filter_map(|entry| entry.ok());
// Build the Tree
let mut tree = Tree::new(parent_directory.to_owned());
let mut files_to_process = Vec::new();
for entry in walker {
let path = entry.path();
if let Ok(relative_path) = path.strip_prefix(&canonical_root_path) {
// Use SelectionEngine if available, otherwise fall back to pattern matching
let entry_match = if let Some(engine) = selection_engine.as_mut() {
engine.is_selected(relative_path)
} else {
should_include_file(relative_path, &include_globset, &exclude_globset)
};
// Directory Tree
let include_in_tree = config.full_directory_tree || entry_match;
if include_in_tree {
let mut current_tree = &mut tree;
for component in relative_path.components() {
let component_str = component.as_os_str().to_string_lossy().to_string();
current_tree = if let Some(pos) = current_tree
.leaves
.iter_mut()
.position(|child| child.root == component_str)
{
&mut current_tree.leaves[pos]
} else {
let new_tree = Tree::new(component_str.clone());
current_tree.leaves.push(new_tree);
current_tree.leaves.last_mut().unwrap()
};
}
}
// Collect files for processing
if path.is_file()
&& entry_match
&& let Ok(metadata) = entry.metadata()
{
files_to_process.push(FileToProcess {
absolute_path: path.to_path_buf(),
relative_path: relative_path.to_path_buf(),
metadata,
});
}
}
}
Ok((tree, files_to_process))
}
/// Phase 2: Processing - Process files in parallel using rayon
///
/// This phase processes files in parallel:
/// - Read file contents (I/O bound)
/// - Process file content (CPU/I/O bound)
/// - Tokenize if enabled (CPU bound)
/// - Build FileEntry structures
fn process_files_parallel(
files_to_process: Vec,
config: &Code2PromptConfig,
) -> Result> {
// Process files in parallel with rayon
let files: Vec