forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1// Shared utility functions for CLI commands
2
3use anyhow::Result;
4use plcbundle::BundleManager;
5use std::path::{Path, PathBuf};
6
7/// ANSI color codes for terminal output
8/// These are shared across all CLI commands for consistent coloring
9pub mod colors {
10 /// Standard green color (used for success, matches, etc.)
11 pub const GREEN: &str = "\x1b[32m";
12
13 /// Standard red color (used for errors, deletions, etc.)
14 pub const RED: &str = "\x1b[31m";
15
16 /// Reset color code
17 pub const RESET: &str = "\x1b[0m";
18
19 /// Dim/bright black color (used for context, unchanged lines, etc.)
20 pub const DIM: &str = "\x1b[2m";
21}
22
23#[cfg(feature = "cli")]
24mod colorize {
25 use colored_json::prelude::*;
26
27 /// Colorize pretty-printed JSON string with syntax highlighting
28 ///
29 /// Uses the colored_json crate which provides jq-compatible colorization.
30 /// Automatically detects if output is a terminal and applies colors accordingly.
31 pub fn colorize_json(json: &str) -> String {
32 // Use to_colored_json_auto which automatically detects terminal and applies colors
33 // This matches jq's behavior of only coloring when output is to a terminal
34 json.to_colored_json_auto()
35 .unwrap_or_else(|_| json.to_string())
36 }
37}
38
39#[cfg(feature = "cli")]
40pub use colorize::colorize_json;
41
42/// Check if stdout is connected to an interactive terminal (TTY)
43///
44/// Returns true if stdout is a TTY, false otherwise.
45/// This is useful for automatically enabling pretty printing and colors
46/// when outputting to a terminal, while using raw output when piping.
47#[cfg(feature = "cli")]
48pub fn is_stdout_tty() -> bool {
49 use is_terminal::IsTerminal;
50 std::io::stdout().is_terminal()
51}
52
53pub use plcbundle::format::{format_bytes, format_bytes_per_sec, format_number};
54
55/// Trait for extracting global flags from command objects
56/// Commands that have verbose/quiet fields should implement this trait
57pub trait HasGlobalFlags {
58 fn verbose(&self) -> bool;
59 fn quiet(&self) -> bool;
60}
61
62/// Parse bundle specification string into a vector of bundle numbers
63pub fn parse_bundle_spec(spec: Option<String>, max_bundle: u32) -> Result<Vec<u32>> {
64 match spec {
65 None => Ok((1..=max_bundle).collect()),
66 Some(s) => {
67 if s.starts_with("latest:") {
68 let count: u32 = s.strip_prefix("latest:").unwrap().parse()?;
69 let start = max_bundle.saturating_sub(count.saturating_sub(1));
70 Ok((start..=max_bundle).collect())
71 } else {
72 use plcbundle::processor::parse_bundle_range;
73 parse_bundle_range(&s, max_bundle)
74 }
75 }
76 }
77}
78
79/// Display path resolving "." to absolute path
80/// Per RULES.md: NEVER display "." in user-facing output
81pub fn display_path(path: &Path) -> PathBuf {
82 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
83}
84
85/// Get number of worker threads, auto-detecting if workers == 0
86///
87/// # Arguments
88/// * `workers` - Number of workers requested (0 = auto-detect)
89/// * `fallback` - Fallback value if auto-detection fails (default: 4)
90///
91/// # Returns
92/// Number of worker threads to use
93pub fn get_worker_threads(workers: usize, fallback: usize) -> usize {
94 if workers == 0 {
95 std::thread::available_parallelism()
96 .map(|n| n.get())
97 .unwrap_or(fallback)
98 } else {
99 workers
100 }
101}
102
103/// Check if repository is empty (no bundles)
104pub fn is_repository_empty(manager: &BundleManager) -> bool {
105 manager.get_last_bundle() == 0
106}
107
108/// Create BundleManager with verbose/quiet flags
109///
110/// This is the standard way to create a BundleManager from CLI commands.
111/// It respects the verbose and quiet flags for logging.
112///
113/// # Arguments
114/// * `dir` - Directory path
115/// * `verbose` - Enable verbose logging
116/// * `_quiet` - Quiet mode (currently unused)
117/// * `preload_mempool` - If true, preload mempool at initialization (for commands that need it)
118pub fn create_manager(
119 dir: PathBuf,
120 verbose: bool,
121 _quiet: bool,
122 preload_mempool: bool,
123) -> Result<BundleManager> {
124 use anyhow::Context;
125
126 // Check if directory exists
127 if !dir.exists() {
128 anyhow::bail!(
129 "Directory does not exist: {}\n\nHint: Make sure you're in a PLC bundle directory, or start a new repository with:\n {} init # Initialize empty repository\n {} clone <url> # Clone from remote",
130 display_path(&dir).display(),
131 plcbundle::constants::BINARY_NAME,
132 plcbundle::constants::BINARY_NAME
133 );
134 }
135
136 // Check if it's a bundle directory (has plc_bundles.json)
137 let index_path = dir.join("plc_bundles.json");
138 if !index_path.exists() {
139 anyhow::bail!(
140 "Not a PLC bundle directory: {}\n\nThis directory does not contain 'plc_bundles.json'.\n\nHint: Make sure you're in a PLC bundle directory, or start a new repository with:\n {} init # Initialize empty repository\n {} clone <url> # Clone from remote",
141 display_path(&dir).display(),
142 plcbundle::constants::BINARY_NAME,
143 plcbundle::constants::BINARY_NAME
144 );
145 }
146
147 let display_dir = display_path(&dir);
148 let options = plcbundle::ManagerOptions {
149 handle_resolver_url: None,
150 preload_mempool,
151 verbose,
152 };
153 let manager = BundleManager::new(dir, options).with_context(|| {
154 format!(
155 "Failed to load bundle repository from: {}",
156 display_dir.display()
157 )
158 })?;
159
160 Ok(manager)
161}
162
163/// Create BundleManager with global flags extracted from command
164///
165/// Convenience function for commands that implement `HasGlobalFlags`.
166/// The global flags (verbose, quiet) are automatically extracted from the command.
167pub fn create_manager_from_cmd<C: HasGlobalFlags>(
168 dir: PathBuf,
169 cmd: &C,
170 preload_mempool: bool,
171) -> Result<BundleManager> {
172 create_manager(dir, cmd.verbose(), cmd.quiet(), preload_mempool)
173}
174
175/// Resolve a target string (bundle number or path) into a bundle number and canonical path.
176/// This utility ensures that file existence checks for bundles are done through the BundleManager.
177pub fn resolve_bundle_target(
178 manager: &BundleManager,
179 target: &str,
180 repo_dir: &PathBuf,
181) -> Result<(Option<u32>, PathBuf)> {
182 // Try to parse as bundle number
183 if let Ok(num) = target.parse::<u32>() {
184 let path = plcbundle::constants::bundle_path(repo_dir, num);
185 // Check if bundle exists via BundleManager's index
186 if manager.get_bundle_metadata(num)?.is_some() {
187 Ok((Some(num), path))
188 } else {
189 anyhow::bail!("Bundle {} not found in repository index", num);
190 }
191 } else {
192 // Otherwise treat as file path. For now, this is an error as direct file access is disallowed.
193 anyhow::bail!(
194 "Loading from arbitrary paths not yet implemented. Please specify a bundle number."
195 );
196 }
197}
198
199/// Get all bundle metadata from the repository
200/// This is more efficient than iterating through bundle numbers
201pub fn get_all_bundle_metadata(manager: &BundleManager) -> Vec<plcbundle::index::BundleMetadata> {
202 manager.get_index().bundles
203}
204
205/// Check available free disk space for a given path
206///
207/// Returns the available free space in bytes, or None if the check fails.
208/// On Unix systems (macOS, Linux), uses statvfs to get filesystem statistics.
209/// On other platforms, returns None (check is skipped).
210#[cfg(unix)]
211pub fn get_free_disk_space(path: &Path) -> Option<u64> {
212 use std::ffi::CString;
213 use std::os::unix::ffi::OsStrExt;
214
215 let c_path = match CString::new(path.as_os_str().as_bytes()) {
216 Ok(p) => p,
217 Err(_) => return None,
218 };
219
220 unsafe {
221 let mut stat: libc::statvfs = std::mem::zeroed();
222 if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 {
223 // Calculate free space: available blocks * block size
224 let free_bytes = stat.f_bavail as u64 * stat.f_frsize;
225 Some(free_bytes)
226 } else {
227 None
228 }
229 }
230}
231
232/// Check available free disk space for a given path
233///
234/// On non-Unix platforms, this function always returns None (check is skipped).
235#[cfg(not(unix))]
236pub fn get_free_disk_space(_path: &Path) -> Option<u64> {
237 None
238}