use crate::core::fs::FileSystemProvider;
use crate::{core, output};
use clap::{Parser, Subcommand, ValueEnum};
use path_absolutize::Absolutize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Parser)]
#[command(name = "packlet")]
#[command(about = "Lightning-fast local dependency bundler")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub config: Option<PathBuf>,
}
#[derive(Subcommand)]
pub enum Commands {
Bundle {
file: PathBuf,
#[arg(short, long, value_enum, default_value = "markdown")]
format: OutputFormat,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
max_depth: Option<usize>,
#[arg(long)]
max_files: Option<usize>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long, value_delimiter = ',')]
extensions: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
exclude: Option<Vec<String>>,
},
Graph {
file: PathBuf,
#[arg(long, value_enum, default_value = "dot")]
format: GraphFormat,
#[arg(long)]
max_depth: Option<usize>,
#[arg(long)]
max_files: Option<usize>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long, value_delimiter = ',')]
exclude: Option<Vec<String>>,
},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum OutputFormat {
Markdown,
Xml,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub enum GraphFormat {
Dot,
Json,
}
fn generate_output_filename(input_file: &Path, format: OutputFormat) -> PathBuf {
let stem = input_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let extension = match format {
OutputFormat::Markdown => "md",
OutputFormat::Xml => "xml",
};
let filename = format!("{}.packlet.{}", stem, extension);
PathBuf::from(filename)
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Commands::Bundle {
file,
format,
output,
max_depth,
max_files,
timeout,
exclude,
..
} => {
let entry_file = file.absolutize()?.to_path_buf();
let output_path = output.unwrap_or_else(|| generate_output_filename(&file, format));
log::info!(
"Bundling {} into {}...",
entry_file.display(),
output_path.display()
);
println!("Bundling: {}", entry_file.display());
println!("Output format: {:?}", format);
let fs_provider = Arc::new(core::fs::CachedFileSystem::new(Box::new(
core::fs::LocalFileSystem,
)));
let extension = entry_file
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let adapter = Arc::from(
core::language::get_adapter_for_extension(extension).ok_or_else(|| {
anyhow::anyhow!("Unsupported file type: {}", entry_file.display())
})?,
);
let context = Arc::new(core::language::AnalysisContext {
fs: fs_provider.clone(),
});
let mut traverser =
core::traverser::DependencyTraverser::new().with_max_depth(max_depth);
if let Some(max_files_limit) = max_files {
traverser = traverser.with_max_files(max_files_limit);
}
if let Some(exclude_patterns) = exclude {
traverser = traverser.with_exclude_patterns(exclude_patterns);
}
println!("Analyzing dependencies...");
let timeout_duration = std::time::Duration::from_secs(timeout.unwrap_or(120));
let graph = tokio::time::timeout(
timeout_duration,
traverser.traverse(&entry_file, adapter, context)
)
.await
.map_err(|_| anyhow::anyhow!(
"Traversal timeout after {} seconds. Try using --max-depth or --max-files to limit scope.",
timeout_duration.as_secs()
))??;
let mut file_contents = std::collections::HashMap::new();
let mut files_to_read = vec![graph.entry_point.clone()];
for (from, deps) in &graph.adj_list {
files_to_read.push(from.clone());
for (to, _) in deps {
files_to_read.push(to.clone());
}
}
files_to_read.sort();
files_to_read.dedup();
println!("Found {} local dependencies", files_to_read.len() - 1);
for file_path in files_to_read {
if let Ok(content) = fs_provider.read_file(&file_path).await {
file_contents.insert(file_path, content);
} else {
log::warn!("Could not read file: {}", file_path.display());
}
}
let formatter: Box<dyn output::OutputFormatter> = match format {
OutputFormat::Markdown => Box::new(output::MarkdownFormatter),
OutputFormat::Xml => {
return Err(anyhow::anyhow!("XML output format is not yet supported"));
}
};
println!("Generating output...");
let git_root = core::fs::find_git_root(&entry_file).await;
let output_str =
formatter.format_with_git_root(&graph, &file_contents, git_root.as_deref())?;
tokio::fs::write(&output_path, output_str).await?;
let metadata = tokio::fs::metadata(&output_path).await?;
let size_kb = metadata.len() as f64 / 1024.0;
println!(
"Successfully created: {} ({:.2} KB)",
output_path.display(),
size_kb
);
println!("Tip: Use --output to specify a custom output location");
}
Commands::Graph {
file,
format,
max_depth,
max_files,
timeout,
exclude,
} => {
let entry_file = file.absolutize()?.to_path_buf();
let git_root = core::fs::find_git_root(&entry_file).await;
let display_path = if let Some(ref root) = git_root {
entry_file
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| entry_file.display().to_string())
} else {
entry_file.display().to_string()
};
println!("Generating graph for {}...", display_path);
let fs_provider = Arc::new(core::fs::CachedFileSystem::new(Box::new(
core::fs::LocalFileSystem,
)));
let extension = entry_file
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let adapter = Arc::from(
core::language::get_adapter_for_extension(extension).ok_or_else(|| {
anyhow::anyhow!("Unsupported file type: {}", entry_file.display())
})?,
);
let context = Arc::new(core::language::AnalysisContext {
fs: fs_provider.clone(),
});
let mut traverser =
core::traverser::DependencyTraverser::new().with_max_depth(max_depth);
if let Some(max_files_limit) = max_files {
traverser = traverser.with_max_files(max_files_limit);
}
if let Some(exclude_patterns) = exclude {
traverser = traverser.with_exclude_patterns(exclude_patterns);
}
println!("Analyzing dependencies...");
let timeout_duration = std::time::Duration::from_secs(timeout.unwrap_or(120));
let graph = tokio::time::timeout(
timeout_duration,
traverser.traverse(&entry_file, adapter, context)
)
.await
.map_err(|_| anyhow::anyhow!(
"Traversal timeout after {} seconds. Try using --max-depth or --max-files to limit scope.",
timeout_duration.as_secs()
))??;
let dep_count = graph
.adj_list
.values()
.map(|deps| deps.len())
.sum::<usize>();
println!("Found {} local dependencies", dep_count);
match format {
GraphFormat::Dot => {
let formatter = output::MarkdownFormatter;
let tree_output =
formatter.format_tree_only_with_git_root(&graph, git_root.as_deref())?;
println!("\n{}", tree_output);
}
GraphFormat::Json => {
let json_output = serde_json::to_string_pretty(&graph)?;
println!("{}", json_output);
}
}
}
}
Ok(())
}