mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(cli): output contract, terminal-safe text, char-safe truncation, cache symlinks
From the full-repo Codex audit: - Provider and KSEI text reached the terminal raw, so an escape sequence in a news title or insight could clear or rewrite the screen. Every table cell, insight line, and graph label now has control characters and bidirectional overrides replaced. - Table rendering sliced strings by byte (a description at 200, a URL at 72, an earnings period), panicking when a multi-byte character such as "’" or "é" fell on the cut. Truncate by character. - In JSON mode, argument errors were clap's plain text and warnings were plain lines ahead of the JSON error. Argument errors are now the usual JSON error (INVALIDINPUT, exit 2) when JSON output is selected, and diagnostics are JSON lines; --help and --version are unchanged. - `config` commands ignored the output format; they follow it like every other command (JSON when piped or with -o json, text on a terminal). - Ownership argument mistakes (--limit 0, --format, missing or conflicting import sources, --family, an empty graph root) returned PARSEERROR / exit 1; they are INVALIDINPUT / exit 2 as documented. - `cache clear` followed symlinked directories and deleted files outside the cache; it now removes links without following them. Output change, intentional: goldens regenerated for the JSON argument error and the five config commands, which run with stdout piped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
57169c75f2
commit
ec2e870c6a
17 changed files with 410 additions and 231 deletions
|
|
@ -185,13 +185,11 @@ impl Cache {
|
|||
}
|
||||
let mut removed = 0usize;
|
||||
let mut failed = Vec::new();
|
||||
Self::walk(&self.root, &mut |p| {
|
||||
if p.is_file() {
|
||||
match fs::remove_file(p) {
|
||||
Ok(_) => removed += 1,
|
||||
Err(_) => failed.push(p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
// `walk` yields files and symlinks, never entering a linked
|
||||
// directory; remove_file on a symlink removes the link, not its target.
|
||||
Self::walk(&self.root, &mut |p| match fs::remove_file(p) {
|
||||
Ok(_) => removed += 1,
|
||||
Err(_) => failed.push(p.to_path_buf()),
|
||||
})?;
|
||||
Ok((removed, failed))
|
||||
}
|
||||
|
|
@ -200,7 +198,10 @@ impl Cache {
|
|||
for entry in fs::read_dir(dir).map_err(|e| IdxError::Io(e.to_string()))? {
|
||||
let entry = entry.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// file_type() does not follow symlinks, so a link to a directory
|
||||
// outside the cache is never walked (or emptied by `clear`).
|
||||
let file_type = entry.file_type().map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
if file_type.is_dir() {
|
||||
Self::walk(&path, f)?;
|
||||
} else {
|
||||
f(&path);
|
||||
|
|
@ -297,6 +298,30 @@ pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn clear_removes_symlinks_without_touching_their_targets() {
|
||||
let base = std::env::temp_dir().join(format!("idx-cache-link-{}", fastrand::u64(..)));
|
||||
let outside = base.join("documents");
|
||||
let root = base.join("cache");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::create_dir_all(root.join("quote")).unwrap();
|
||||
std::fs::write(outside.join("keep.txt"), b"mine").unwrap();
|
||||
std::fs::write(root.join("quote").join("BBCA.json"), b"{}").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||
|
||||
let (removed, failed) = super::Cache::at(root.clone()).clear().unwrap();
|
||||
|
||||
assert!(failed.is_empty(), "{failed:?}");
|
||||
assert_eq!(removed, 2, "the cache file and the link itself");
|
||||
assert!(
|
||||
outside.join("keep.txt").exists(),
|
||||
"the link's target is left alone"
|
||||
);
|
||||
assert!(!root.join("link").exists());
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
|
|
|
|||
|
|
@ -635,7 +635,7 @@ pub fn parse_report_family(raw: &str) -> Result<Option<OwnershipReportFamily>, I
|
|||
"investor-type" | "investor_type" | "investortype" => {
|
||||
Ok(Some(OwnershipReportFamily::InvestorTypeBreakdown))
|
||||
}
|
||||
_ => Err(IdxError::ParseError(
|
||||
_ => Err(IdxError::InvalidInput(
|
||||
"invalid --family, expected: all|above1|above5|investor-type".to_string(),
|
||||
)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod runtime {
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static QUIET: AtomicBool = AtomicBool::new(false);
|
||||
static JSON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn set_quiet(quiet: bool) {
|
||||
QUIET.store(quiet, Ordering::Relaxed);
|
||||
|
|
@ -32,15 +33,29 @@ pub mod runtime {
|
|||
QUIET.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn info(message: impl Display) {
|
||||
if !is_quiet() {
|
||||
eprintln!("info: {message}");
|
||||
/// In JSON mode diagnostics are JSON lines, so stderr stays
|
||||
/// machine-readable alongside a JSON error.
|
||||
pub fn set_json(json: bool) {
|
||||
JSON.store(json, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn emit(level: &str, message: impl Display) {
|
||||
if is_quiet() {
|
||||
return;
|
||||
}
|
||||
if JSON.load(Ordering::Relaxed) {
|
||||
let line = serde_json::json!({ "level": level, "message": message.to_string() });
|
||||
eprintln!("{line}");
|
||||
} else {
|
||||
eprintln!("{level}: {message}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(message: impl Display) {
|
||||
emit("info", message);
|
||||
}
|
||||
|
||||
pub fn warn(message: impl Display) {
|
||||
if !is_quiet() {
|
||||
eprintln!("warning: {message}");
|
||||
}
|
||||
emit("warning", message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub fn query_ownership_graph(
|
|||
) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), IdxError> {
|
||||
let root = root.trim();
|
||||
if root.is_empty() {
|
||||
return Err(IdxError::ParseError(
|
||||
return Err(IdxError::InvalidInput(
|
||||
"graph root cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue