diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc703d..e0569e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- The CLI prints one GitHub star line to stderr after the fifth successful interactive run, once ever (counter in the cache directory). It is silent for the MCP server, with `--json`, in CI, and when stderr is not a terminal; `LOCKDOCS_NO_STAR_HINT=1` turns it off. + ## 0.3.0 - **Docs sites follow your major.** For packages whose docs live in a separate website repository, `lockdocs fetch` takes the docs for the pinned major: the default branch when your major is the latest, a `vN` / `N.x` branch when the site keeps one (Tailwind CSS v3, Prisma v6), or the last commit before the next major was released. Pages about a later major are skipped. React keeps the latest-only rule (react.dev documents APIs before they ship). tokio's website (tutorial and topics) is added, and docs sites now work for crates and PyPI packages too. diff --git a/crates/lockdocs/src/main.rs b/crates/lockdocs/src/main.rs index 41ef232..57f96a9 100644 --- a/crates/lockdocs/src/main.rs +++ b/crates/lockdocs/src/main.rs @@ -1,5 +1,6 @@ mod mcp; mod setup; +mod star_hint; mod tools; use anyhow::{bail, Result}; @@ -151,7 +152,12 @@ fn run() -> Result<()> { } let json = args.on("json"); let engine = || Engine::new(&args.root(), args.opts()); - match cmd.as_str() { + let counts_as_run = !json + && !matches!( + cmd.as_str(), + "mcp" | "serve" | "help" | "--help" | "-h" | "version" | "--version" | "-V" | "setup" | "cache" | "fetch" | "pull" + ); + let result = match cmd.as_str() { "mcp" | "serve" => { let offline = args.on("offline"); // Fetch the model in the background; queries are keyword-only until it lands. @@ -207,7 +213,11 @@ fn run() -> Result<()> { Err(err) => bail!("{err}"), } } + }; + if result.is_ok() && counts_as_run { + star_hint::after_success(); } + result } fn ensure_model(offline: bool) { diff --git a/crates/lockdocs/src/star_hint.rs b/crates/lockdocs/src/star_hint.rs new file mode 100644 index 0000000..575a2b3 --- /dev/null +++ b/crates/lockdocs/src/star_hint.rs @@ -0,0 +1,134 @@ +//! A one-time star line on stderr after the fifth successful CLI run. +//! +//! The run counter lives in the lockdocs cache directory (`LOCKDOCS_CACHE`). The line is shown once +//! ever, and never for the MCP server, for a non-TTY stderr, in CI, or when +//! `LOCKDOCS_NO_STAR_HINT` is set. + +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +pub const OPT_OUT_ENV: &str = "LOCKDOCS_NO_STAR_HINT"; +const FILE_NAME: &str = "star-hint"; +const SHOWN: &str = "shown"; +const SHOW_AFTER_RUNS: u32 = 5; +const MESSAGE: &str = "Enjoying lockdocs? A GitHub star helps others find it: https://github.com/SylphxAI/lockdocs"; + +/// What the environment says about where the run is happening. +pub struct Context { + pub stderr_is_tty: bool, + pub ci: bool, + pub opted_out: bool, +} + +impl Context { + fn from_process() -> Self { + let set = |key: &str| std::env::var_os(key).is_some_and(|v| !v.is_empty()); + Context { + stderr_is_tty: std::io::stderr().is_terminal(), + ci: set("CI"), + opted_out: set(OPT_OUT_ENV), + } + } + + fn quiet(&self) -> bool { + !self.stderr_is_tty || self.ci || self.opted_out + } +} + +/// Decide from the stored counter text. Returns the text to store next and +/// whether to print the line now. +pub fn step(stored: Option<&str>, context: &Context) -> (Option, bool) { + if context.quiet() { + return (None, false); + } + let stored = stored.map(str::trim).unwrap_or(""); + if stored == SHOWN { + return (None, false); + } + let runs = stored.parse::().unwrap_or(0).saturating_add(1); + if runs >= SHOW_AFTER_RUNS { + (Some(SHOWN.to_string()), true) + } else { + (Some(runs.to_string()), false) + } +} + +fn state_path() -> PathBuf { + lockdocs_core::cache::dir().join(FILE_NAME) +} + +fn apply(path: &Path, context: &Context) -> bool { + let stored = std::fs::read_to_string(path).ok(); + let (next, show) = step(stored.as_deref(), context); + if let Some(next) = next { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::write(path, next).is_err() { + return false; + } + } + show +} + +/// Call after a CLI run that succeeded. Best effort: any file error is silent. +pub fn after_success() { + let context = Context::from_process(); + if context.quiet() { + return; + } + if apply(&state_path(), &context) { + eprintln!("{MESSAGE}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context(stderr_is_tty: bool, ci: bool, opted_out: bool) -> Context { + Context { stderr_is_tty, ci, opted_out } + } + + #[test] + fn counts_four_runs_then_shows_once() { + let interactive = context(true, false, false); + let mut stored: Option = None; + for run in 1..=4 { + let (next, show) = step(stored.as_deref(), &interactive); + assert!(!show, "run {run}"); + stored = next; + } + assert_eq!(stored.as_deref(), Some("4")); + let (next, show) = step(stored.as_deref(), &interactive); + assert!(show); + assert_eq!(next.as_deref(), Some("shown")); + let (next, show) = step(next.as_deref(), &interactive); + assert!(!show); + assert_eq!(next, None); + } + + #[test] + fn stays_silent_and_uncounted_when_gated() { + for quiet in [context(false, false, false), context(true, true, false), context(true, false, true)] { + assert_eq!(step(Some("4"), &quiet), (None, false)); + } + } + + #[test] + fn garbage_counter_restarts() { + let interactive = context(true, false, false); + assert_eq!(step(Some("x"), &interactive), (Some("1".into()), false)); + } + + #[test] + fn persists_and_shows_on_the_fifth_run_only() { + let dir = std::env::temp_dir().join(format!("lockdocs-star-hint-{}", std::process::id())); + let path = dir.join("nested").join(FILE_NAME); + let interactive = context(true, false, false); + let shown: Vec = (0..7).map(|_| apply(&path, &interactive)).collect(); + assert_eq!(shown, [false, false, false, false, true, false, false]); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "shown"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/guide/setup.md b/docs/guide/setup.md index 80453af..0d8f137 100644 --- a/docs/guide/setup.md +++ b/docs/guide/setup.md @@ -21,4 +21,5 @@ | `LOCKDOCS_ROOT` | Project directory when the client sends no roots | | `LOCKDOCS_FETCH=1` | Allow [fetching](./fetch) exact versions that are not installed | | `LOCKDOCS_CACHE` | Cache directory (default: the OS cache dir + `/lockdocs`) | +| `LOCKDOCS_NO_STAR_HINT=1` | Turn off the one-time GitHub star line the CLI prints to stderr after the fifth successful interactive run (never shown for the MCP server, with `--json`, in CI, or when stderr is not a terminal) | | `LOCKDOCS_NO_SYSTEM_PYTHON=1` | Do not ask the system interpreter for its site-packages |