Skip to content
Draft
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
385 changes: 343 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ include = [

[dependencies]
anyhow = "1.0.100"
clap = { version = "4.5.49", features = ["derive"] }
clap = { version = "4.5.49", features = ["derive", "env"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
unicode-segmentation = "1.12.0"
Expand All @@ -45,11 +45,11 @@ ignore = "0.4"
yaml-rust2 = "0.11"
once_cell = "1.19"
syntect = "5"
rayon = "1"
regex = "1.11"
clap_complete = "4.5.62"
prunist = { path = "crates/prunist", version = "0.17.0" }


uuid = { version = "1", features = ["v4"] }

[features]
default = []
Expand All @@ -70,6 +70,7 @@ rand = "0.9"
rand_chacha = "0.9"
tempfile = "3"
serde_yaml = "0.9"
serial_test = "3"

[profile.release]
# Prioritize runtime speed for large inputs
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Available as:
- Multi-file mode: preview many files at once (paths, `--glob ...`, or `--recursive` on directories) with shared or per-file budgets
- Repo-aware ordering: in git repos, frequent+recent files show up first (rarely touched files drift to the end; mtime fallback)
- `grep`-like search and `tree`-like view: `--grep <regex>` and `--tree` emulate the workflows while still summarizing file contents inline
- Explore sessions: with an active session (`hson explore start`), repeated runs under tight budgets surface fresh content instead of repeating the same preview
- Fast: processes gigabyte‑scale files in seconds (mostly disk‑bound)
- Available as a CLI app and as a Python library

Expand Down Expand Up @@ -143,6 +144,7 @@ hson -n 20 src/main.py
- [Budget modes](#budget-modes)
- [Text mode](#text-mode)
- [Source code support](#source-code-support)
- [Explore sessions](#explore-sessions)

#### Common flags

Expand Down Expand Up @@ -273,6 +275,43 @@ For source code files, headson uses an indentation-aware heuristic to build an o
- Under tight budgets, it tends to keep block-introducing lines (like function/class headers) and omit less relevant blocks from the middle.
- With colors enabled, you also get syntax highlighting and line numbers.

#### Explore sessions

With an active explore session, `hson` remembers which leaves it already showed (its “breadcrumbs”) and penalizes them in priority scoring. Repeated runs under a tight budget surface fresh content instead of the same preview every time; under a loose budget previously-seen items still appear — the penalty is soft, never an exclusion.

```bash
export HSON_SESSION=$(hson explore start) # create a session; prints a bare UUID
hson src/ -C 8000 --tree # first look
hson src/ -C 8000 --tree # same command, fresher content
hson explore status # id, label, step count, breadcrumbs, last activity
hson explore list # chronological query log
hson explore clear # forget breadcrumbs; keep the query log
```

Subcommands:

- `hson explore start [label]`: create a new session and print its UUID to stdout (bare, so it can be assigned directly to `HSON_SESSION`). The label defaults to `Explore session started originally in <cwd>`.
- `hson explore status`: show the active session: id, label, step count, breadcrumb count, and last activity.
- `hson explore clear`: clear the breadcrumbs and reset the step count; the query log and label are preserved.
- `hson explore list`: print the session’s query log in chronological order.

Flags (on normal `hson` invocations):

- `--session <uuid>`: activate a session for this invocation only, overriding `HSON_SESSION`.
- `--no-record`: apply the session’s penalties but write nothing back (no breadcrumbs, no query log entry, no step increment).
- `--explore-decay <alpha>`: per-step penalty decay factor (default: 0.5). With the default, content you saw a few steps ago is already mostly “forgiven”; use `1.0` for no decay (seen content keeps its full penalty for the rest of the session).
- `--explore-memory <N>`: maximum breadcrumbs retained per session (default: 10000). Oldest entries are evicted first.

Notes:

- Sessions are only created by `hson explore start`. An unknown session ID — via `--session` or `HSON_SESSION` — is an error, so a typo can’t silently start a fresh session and lose the bias context of the one you meant. An empty `HSON_SESSION` is treated as unset, and an invalid one never blocks `hson explore start` (you only get a warning).
- The `explore` subcommand shadows an input literally named `explore`: `hson explore` in a directory containing `explore/` shows the subcommand help. Use `hson ./explore` to preview such a file or directory.
- Stdin input is never tracked: no breadcrumbs are recorded and no penalties apply.
- Session state lives at `$XDG_STATE_HOME/headson/sessions/<uuid>.json` (falling back to `~/.local/state` when `XDG_STATE_HOME` is unset).
- Breadcrumbs are content-addressed: when a value changes on disk, its old breadcrumb stops matching and the penalty disappears automatically.
- Breadcrumbs are also keyed by the file’s resolved absolute path, so identical values in two different files never share a penalty, and the same file stays recognized across working directories and invocation styles.
- Concurrent invocations on the same session are safe (a session lock plus atomic writes).

Show help:

```sh
Expand Down
2 changes: 1 addition & 1 deletion python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ crate-type = ["cdylib"]

[dependencies]
anyhow = "1"
pyo3 = { version = "0.27", features = ["extension-module", "abi3-py310"] }
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py310"] }
headson_core = { package = "headson", path = ".." }
regex = "1"
232 changes: 231 additions & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
use std::path::PathBuf;

use clap::{ArgAction, Parser, ValueEnum};
use clap::{ArgAction, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;

fn parse_session_id(s: &str) -> Result<String, String> {
uuid::Uuid::parse_str(s)
.map(|u| u.to_string())
.map_err(|e| format!("invalid session ID (must be a UUID): {e}"))
}

fn parse_explore_decay(s: &str) -> Result<f64, String> {
let alpha: f64 = s
.parse()
.map_err(|e| format!("invalid decay factor: {e}"))?;
if alpha > 0.0 && alpha <= 1.0 {
Ok(alpha)
} else {
Err(format!(
"decay factor must satisfy 0.0 < ALPHA <= 1.0 (got {alpha})"
))
}
}

fn parse_explore_memory(s: &str) -> Result<usize, String> {
let n: usize = s
.parse()
.map_err(|e| format!("invalid breadcrumb capacity: {e}"))?;
if n >= 1 {
Ok(n)
} else {
Err("breadcrumb capacity must be at least 1".to_string())
}
}

/// Top-level CLI flags and enums.
#[derive(Parser, Debug)]
#[command(
Expand Down Expand Up @@ -268,13 +298,80 @@ pub struct Cli {
help_heading = "Filtering"
)]
pub grep_show: GrepShowArg,
// HSON_SESSION is intentionally NOT wired through clap's `env` attribute:
// env resolution happens in `session_middleware::resolve_session_id` so an
// empty value acts as unset and an invalid value cannot fail parsing for
// every invocation (including `hson explore start`, the escape hatch).
#[arg(
long = "session",
value_name = "SESSION_ID",
global = true,
value_parser = parse_session_id,
help = "Activate an explore session by ID (UUID). Falls back to a non-empty HSON_SESSION environment variable.",
help_heading = "Explore"
)]
pub session: Option<String>,
#[arg(
long = "no-record",
action = ArgAction::SetTrue,
help = "Apply session penalty without recording breadcrumbs or incrementing step count.",
help_heading = "Explore"
)]
pub no_record: bool,
#[arg(
long = "explore-decay",
value_name = "ALPHA",
default_value_t = crate::cli::session_middleware::DEFAULT_ALPHA,
value_parser = parse_explore_decay,
help = "Decay factor per step for session novelty penalties (0 < ALPHA <= 1; 1.0 = no decay). Only takes effect with an active session.",
help_heading = "Explore"
)]
pub explore_decay: f64,
#[arg(
long = "explore-memory",
value_name = "N",
default_value_t = crate::cli::session_middleware::BREADCRUMB_CAP,
value_parser = parse_explore_memory,
help = "Maximum breadcrumbs retained per session; older entries are evicted. Only takes effect with an active session.",
help_heading = "Explore"
)]
pub explore_memory: usize,
#[arg(
long = "completions",
value_name = "SHELL",
value_enum,
help = "Print shell completions for the given shell"
)]
pub completions: Option<Shell>,
#[command(subcommand)]
pub subcommand: Option<TopSubcommand>,
}

#[derive(Subcommand, Debug)]
pub enum TopSubcommand {
/// Manage explore sessions (novelty-bias mode)
Explore(ExploreArgs),
}

#[derive(clap::Args, Debug)]
pub struct ExploreArgs {
#[command(subcommand)]
pub command: ExploreSubcommand,
}

#[derive(Subcommand, Debug)]
pub enum ExploreSubcommand {
/// Start a new explore session and print the session ID to stdout
Start {
/// Optional human-readable label for this session
label: Option<String>,
},
/// Show the current session status
Status,
/// Clear breadcrumb memory (query log and label are preserved)
Clear,
/// Print the query log for the current session in chronological order
List,
}

#[derive(Copy, Clone, Debug, ValueEnum)]
Expand Down Expand Up @@ -403,3 +500,136 @@ pub fn print_completions<G: clap_complete::Generator>(
&mut std::io::stdout(),
);
}

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

#[test]
fn session_flag_can_appear_after_explore_subcommand() {
use clap::Parser;
let result = Cli::try_parse_from([
"hson",
"explore",
"status",
"--session",
"00000000-0000-0000-0000-000000000000",
]);
assert!(
result.is_ok(),
"--session must be accepted after `explore status`; got: {:?}",
result.err()
);
let cli = result.unwrap();
assert_eq!(
cli.session.as_deref(),
Some("00000000-0000-0000-0000-000000000000")
);
}

#[test]
fn non_uuid_session_value_fails_to_parse() {
use clap::Parser;
let result =
Cli::try_parse_from(["hson", "--session", "not-a-uuid", "file"]);
assert!(
result.is_err(),
"non-UUID session value must fail at parse time; got Ok with cli.session={:?}",
result.ok().and_then(|c| c.session)
);
}

#[test]
fn empty_session_value_fails_to_parse() {
use clap::Parser;
let result = Cli::try_parse_from(["hson", "--session", "", "file"]);
assert!(
result.is_err(),
"empty session value must fail at parse time"
);
}

#[test]
fn path_traversal_session_value_fails_to_parse() {
use clap::Parser;
let result = Cli::try_parse_from([
"hson",
"--session",
"../../etc/passwd",
"file",
]);
assert!(
result.is_err(),
"session value containing path separators must fail at parse time"
);
}

#[test]
fn explore_decay_and_memory_use_documented_defaults() {
use clap::Parser;
let cli = Cli::try_parse_from(["hson", "file"])
.expect("plain invocation must parse");
assert!(
(cli.explore_decay - 0.5).abs() < f64::EPSILON,
"default --explore-decay must be 0.5; got {}",
cli.explore_decay
);
assert_eq!(
cli.explore_memory, 10_000,
"default --explore-memory must be 10000"
);
}

#[test]
fn explore_decay_rejects_out_of_range_values() {
use clap::Parser;
for bad in ["0", "0.0", "1.5", "nan"] {
let result =
Cli::try_parse_from(["hson", "--explore-decay", bad, "file"]);
assert!(
result.is_err(),
"--explore-decay {bad} must fail at parse time"
);
}
}

#[test]
fn explore_decay_accepts_boundary_and_interior_values() {
use clap::Parser;
for good in ["1.0", "0.5", "0.001"] {
let result =
Cli::try_parse_from(["hson", "--explore-decay", good, "file"]);
assert!(
result.is_ok(),
"--explore-decay {good} must parse; got: {:?}",
result.err()
);
}
}

#[test]
fn explore_memory_rejects_zero_and_accepts_one() {
use clap::Parser;
assert!(
Cli::try_parse_from(["hson", "--explore-memory", "0", "file"])
.is_err(),
"--explore-memory 0 must fail at parse time"
);
let cli =
Cli::try_parse_from(["hson", "--explore-memory", "1", "file"])
.expect("--explore-memory 1 must parse");
assert_eq!(cli.explore_memory, 1);
}

#[test]
fn valid_uuid_session_value_parses_successfully() {
use clap::Parser;
let result = Cli::try_parse_from([
"hson",
"--session",
"00000000-0000-0000-0000-000000000000",
"file",
]);
assert!(result.is_ok(), "valid UUID must parse: {:?}", result.err());
}
}
Loading
Loading