Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ Cargo.lock

LICENSE

CLAUDE.md
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ categories = ["command-line-utilities"]
exclude = [".github/"]

[dependencies]
chrono = "0.4"
clap = { version = "4.5.26", features = ["derive"] }
glob = "0.3.2"
ignore = "0.4.23"
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ Options:
--ignore-gitignore Ignore .gitignore files
--ignore <PATTERN> Patterns to ignore
-o, --output <FILE> Write output to a file
--max-file-size <SIZE> Maximum file size to process (e.g., 1MB, 256KB)
--max-lines <COUNT> Maximum number of lines to include per file
--add-metadata Add file metadata (size, lines, modification date) to headers
-h, --help Print help
-V, --version Print version
```
Expand All @@ -37,3 +40,39 @@ Exclude tests dir:
proompt --ignore tests/* . | pbcopy
```

## New Features

### File Size Limiting
Skip files larger than specified size:
```sh
proompt --max-file-size 1MB . | pbcopy
proompt --max-file-size 256KB src/ | pbcopy
```

### Line Limiting
Limit the number of lines per file:
```sh
proompt --max-lines 50 . | pbcopy
proompt --max-lines 100 src/ | pbcopy
```

### Add File Metadata
Include file information in headers:
```sh
proompt --add-metadata . | pbcopy
```

Output format with metadata:
```
src/main.rs (150 lines, 4.2KB, modified: 2025-01-15)
---
[File contents]
---
```

### Combined Usage
Combine multiple options:
```sh
proompt --add-metadata --max-lines 100 --max-file-size 500KB src/ | pbcopy
```

189 changes: 179 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use clap::Parser;
use ignore::WalkBuilder;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Concatenate a directory full of files into a single prompt for use with LLMs
#[derive(Parser, Debug)]
Expand Down Expand Up @@ -29,34 +30,202 @@ struct Args {
/// Write output to a file
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,

/// Maximum file size to process (e.g., 1MB, 256KB)
#[arg(long, value_name = "SIZE")]
max_file_size: Option<String>,

/// Maximum number of lines to include per file
#[arg(long, value_name = "COUNT")]
max_lines: Option<usize>,

/// Add file metadata (size, lines, modification date) to headers
#[arg(long)]
add_metadata: bool,
}

fn parse_file_size(size_str: &str) -> Result<u64, String> {
let size_str = size_str.trim().to_uppercase();
let (num_str, unit) = if size_str.ends_with("KB") {
(&size_str[..size_str.len() - 2], 1024u64)
} else if size_str.ends_with("MB") {
(&size_str[..size_str.len() - 2], 1024u64 * 1024)
} else if size_str.ends_with("GB") {
(&size_str[..size_str.len() - 2], 1024u64 * 1024 * 1024)
} else if size_str.ends_with("B") {
(&size_str[..size_str.len() - 1], 1u64)
} else {
(size_str.as_str(), 1u64)
};

num_str
.parse::<u64>()
.map(|n| n * unit)
.map_err(|_| format!("Invalid file size format: {}", size_str))
}

fn get_file_metadata(path: &Path, line_count: usize) -> Result<(u64, usize, String), std::io::Error> {
let metadata = fs::metadata(path)?;
let size = metadata.len();

let modified_time = metadata
.modified()
.unwrap_or(SystemTime::now())
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

let modified_date = chrono::DateTime::from_timestamp(modified_time as i64, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".to_string());

Ok((size, line_count, modified_date))
}

fn format_file_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;

if size >= GB {
format!("{:.1}GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1}MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1}KB", size as f64 / KB as f64)
} else {
format!("{}B", size)
}
}

fn process_file(path: &Path, writer: &mut impl std::io::Write) -> Result<(), std::io::Error> {
match fs::read_to_string(path) {
Ok(content) => {
writeln!(writer, "{}", path.display())?;
writeln!(writer, "---")?;
writeln!(writer, "{}", content)?;
writeln!(writer, "---")?;
fn process_file(
path: &Path,
args: &Args,
writer: &mut impl std::io::Write,
) -> Result<(), std::io::Error> {
// Check file size limit
if let Some(max_size_str) = &args.max_file_size {
let max_size = parse_file_size(max_size_str)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;

let file_size = fs::metadata(path)?.len();
if file_size > max_size {
eprintln!(
"Warning: Skipping file {} due to size limit ({} > {})",
path.display(),
format_file_size(file_size),
max_size_str
);
return Ok(());
}
}

let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
eprintln!(
"Warning: Skipping file {} due to UnicodeDecodeError",
path.display()
);
return Ok(());
}
Err(e) => return Err(e),
};

// Calculate line count once (needed for both metadata and truncation)
let total_lines = content.lines().count();

// Apply line limit if specified
let processed_content = if let Some(max_lines) = args.max_lines {
let lines: Vec<&str> = content.lines().take(max_lines).collect();
let truncated = total_lines > max_lines;
let mut result = lines.join("\n");
if truncated {
result.push_str("\n... (truncated)");
}
result
} else {
content
};

// Write file header with optional metadata
if args.add_metadata {
match get_file_metadata(path, total_lines) {
Ok((size, line_count, modified_date)) => {
writeln!(
writer,
"{} ({} lines, {}, modified: {})",
path.display(),
line_count,
format_file_size(size),
modified_date
)?;
}
Err(_) => {
writeln!(writer, "{}", path.display())?;
}
}
} else {
writeln!(writer, "{}", path.display())?;
}

writeln!(writer, "---")?;
writeln!(writer, "{}", processed_content)?;
writeln!(writer, "---")?;

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn test_parse_file_size() {
assert_eq!(parse_file_size("1024").unwrap(), 1024);
assert_eq!(parse_file_size("1KB").unwrap(), 1024);
assert_eq!(parse_file_size("1MB").unwrap(), 1024 * 1024);
assert_eq!(parse_file_size("1GB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_file_size("2MB").unwrap(), 2 * 1024 * 1024);
assert!(parse_file_size("invalid").is_err());
assert!(parse_file_size("1XB").is_err());
}

#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(512), "512B");
assert_eq!(format_file_size(1024), "1.0KB");
assert_eq!(format_file_size(1536), "1.5KB");
assert_eq!(format_file_size(1024 * 1024), "1.0MB");
assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0GB");
}

#[test]
fn test_get_file_metadata() {
let dir = tempdir().unwrap();
let test_file = dir.path().join("test_temp_file.txt");
std::fs::write(&test_file, "line1\nline2\nline3").unwrap();

// Follow the same pattern as real-world usage: read content first, then get metadata
let content = std::fs::read_to_string(&test_file).unwrap();
let line_count = content.lines().count();

let (size, metadata_line_count, _date) = get_file_metadata(&test_file, line_count).unwrap();
assert_eq!(metadata_line_count, 3);
assert_eq!(size, 17); // "line1\nline2\nline3" = 17 bytes

// No need to remove file - tempdir will handle cleanup
}
}

fn process_path(
path: &Path,
args: &Args,
writer: &mut impl std::io::Write,
) -> Result<(), std::io::Error> {
if path.is_file() {
process_file(path, writer)?;
process_file(path, args, writer)?;
} else if path.is_dir() {
let walker = WalkBuilder::new(path)
.hidden(!args.include_hidden)
Expand Down Expand Up @@ -98,11 +267,11 @@ fn process_path(
};

if should_process {
process_file(entry.path(), writer)?;
process_file(entry.path(), args, writer)?;
}
}
}
Err(err) => eprintln!("ERROR: {}", err),
Err(err) => eprintln!("Error: {}", err),
}
}
}
Expand Down
Loading