1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
//! `aube cat-index <pkg@version>` — print the cached package index JSON.
//!
//! Prints the index that `aube fetch`/`aube install` writes under
//! `~/.cache/aube/index/<name>@<version>.json`: a mapping of relative paths
//! in the package to their store file hashes. Useful for debugging linker
//! behavior or confirming which files landed in the CAS.
//!
//! The package must have been fetched by aube at least once — if the cache
//! is cold for that version, we surface a friendly error pointing at
//! `aube fetch`. This is a read-only introspection command: no lockfile,
//! no node_modules, no project lock.
use clap::Args;
use miette::{IntoDiagnostic, miette};
#[derive(Debug, Args)]
pub struct CatIndexArgs {
/// Package to inspect, in `name@version` form (e.g. `lodash@4.17.21`,
/// `@babel/core@7.26.0`).
///
/// An exact version is required — ranges and dist-tags aren't
/// resolved here.
pub package: String,
}
pub async fn run(args: CatIndexArgs) -> miette::Result<()> {
let (name, version) = split_name_version(&args.package).ok_or_else(|| {
miette!(
"expected `name@version`, got `{}`\nhelp: specify an exact version like `lodash@4.17.21`",
args.package
)
})?;
let cwd = crate::dirs::project_root_or_cwd()?;
let store = crate::commands::open_store(&cwd)?;
// Read the cached index file directly instead of routing through
// `Store::load_index` — that helper silently *deletes* the cache
// entry if it detects the underlying store files are missing, which
// would be a surprising mutation from a read-only introspection
// command (the user would see "no cached index" when the JSON was
// in fact present the moment before and has now been removed).
// Re-serialize the parsed index so the output is pretty-printed the
// same way load_index would have given us.
let safe_name = name.replace('/', "__");
let index_path = store
.index_dir()
.join(format!("{safe_name}@{version}.json"));
let content = std::fs::read_to_string(&index_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
miette!(
"no cached index for {name}@{version}\nhelp: run `aube fetch` or `aube install` to populate the store first"
)
} else {
miette!("failed to read {}: {e}", index_path.display())
}
})?;
let index: aube_store::PackageIndex = serde_json::from_str(&content)
.into_diagnostic()
.map_err(|e| {
miette!(
"cached index for {name}@{version} is corrupt: {e}\nhelp: re-run `aube fetch` to regenerate it"
)
})?;
let json = serde_json::to_string_pretty(&index)
.into_diagnostic()
.map_err(|e| miette!("failed to serialize index: {e}"))?;
println!("{json}");
Ok(())
}
/// Split `name@version` into its parts, respecting scoped packages.
/// Returns `None` if no `@version` is present, or if the version half
/// is empty (`lodash@`, `@babel/core@`) — cat-index needs an exact
/// version, so both bare names and trailing-`@` typos are rejected up
/// front so the user gets the format hint instead of the misleading
/// "cache cold, run aube fetch" error.
fn split_name_version(input: &str) -> Option<(&str, &str)> {
let (name, version) = if let Some(rest) = input.strip_prefix('@') {
// Scoped: @scope/name@version — the first `@` is the scope sigil.
let slash = rest.find('/')?;
let after_slash = &rest[slash + 1..];
let at = after_slash.find('@')?;
let name_end = 1 + slash + 1 + at;
(&input[..name_end], &input[name_end + 1..])
} else {
let at = input.find('@')?;
(&input[..at], &input[at + 1..])
};
if version.is_empty() {
return None;
}
Some((name, version))
}
#[cfg(test)]
mod tests {
use super::split_name_version;
#[test]
fn plain_name_version() {
assert_eq!(
split_name_version("lodash@4.17.21"),
Some(("lodash", "4.17.21"))
);
}
#[test]
fn scoped_name_version() {
assert_eq!(
split_name_version("@babel/core@7.26.0"),
Some(("@babel/core", "7.26.0"))
);
}
#[test]
fn rejects_missing_version() {
assert_eq!(split_name_version("lodash"), None);
assert_eq!(split_name_version("@babel/core"), None);
}
#[test]
fn rejects_empty_version_after_at() {
// Trailing-`@` typos would otherwise reach the store with an
// empty version string and surface a misleading "cache cold"
// error instead of the format hint.
assert_eq!(split_name_version("lodash@"), None);
assert_eq!(split_name_version("@babel/core@"), None);
}
}