ite-cli 0.2.2

Interactive terminal tree explorer for filesystems and JSON
Documentation
//! The declarative command-line surface: clap argument definitions and parsing
//! for expansion policy, filesystem/JSON source selection, ignore behavior,
//! and configuration paths.
//!
//! This module describes user input but performs no I/O and does not decide the
//! implicit source. The executable interprets `Cli`, loads files/stdin, and
//! passes the resulting settings into the application.

use std::path::PathBuf;
use std::str::FromStr;

use clap::Parser;

/// Argument to `--expand`: a depth or `all`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExpandSpec {
    Depth(usize),
    All,
}

impl FromStr for ExpandSpec {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.eq_ignore_ascii_case("all") {
            return Ok(Self::All);
        }
        s.parse()
            .map(Self::Depth)
            .map_err(|_| format!("expected a depth or `all`, got {s:?}"))
    }
}

/// ite — interactive tree explorer.
#[derive(Parser, Debug)]
#[command(name = "ite", version, about)]
pub struct Cli {
    /// Directory or .json/.jsonl file to explore (defaults to the current directory).
    pub path: Option<PathBuf>,

    /// JSON file to explore instead of a directory; `-` reads stdin.
    /// Piped stdin with no PATH is read as JSON automatically. JSONL
    /// content is detected from the content itself.
    #[arg(short, long, value_name = "PATH", conflicts_with = "path")]
    pub json: Option<PathBuf>,

    /// JSON Lines file to explore; `-` reads stdin. Forces the JSONL
    /// reading when detection can't be sure (e.g. a single record).
    #[arg(short = 'l', long, value_name = "PATH", conflicts_with_all = ["path", "json"])]
    pub jsonl: Option<PathBuf>,

    /// Do not respect ignore files (.gitignore etc.).
    #[arg(short = 'I', long)]
    pub no_ignore: bool,

    /// Expand all non-leaves at or below depth N, or `all` for everything.
    #[arg(short, long)]
    pub expand: Option<ExpandSpec>,

    /// Config file to load instead of the user config; repeatable.
    #[arg(short, long)]
    pub config: Vec<PathBuf>,
}

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

    fn parse(args: &[&str]) -> Cli {
        Cli::try_parse_from(std::iter::once("ite").chain(args.iter().copied())).unwrap()
    }

    #[test]
    fn defaults() {
        let cli = parse(&[]);
        assert_eq!(cli.path, None);
        assert_eq!(cli.json, None);
        assert_eq!(cli.jsonl, None);
        assert!(!cli.no_ignore);
        assert_eq!(cli.expand, None);
        assert!(cli.config.is_empty());
    }

    #[test]
    fn positional_path() {
        assert_eq!(parse(&["/some/dir"]).path, Some(PathBuf::from("/some/dir")));
    }

    #[test]
    fn json_path_flag_and_alias() {
        assert_eq!(
            parse(&["--json", "data.json"]).json,
            Some(PathBuf::from("data.json"))
        );
        assert_eq!(
            parse(&["-j", "other.json"]).json,
            Some(PathBuf::from("other.json"))
        );
    }

    #[test]
    fn json_and_directory_paths_are_mutually_exclusive() {
        assert!(Cli::try_parse_from(["ite", "--json", "data.json", "/some/dir"]).is_err());
    }

    #[test]
    fn jsonl_path_flag_and_alias() {
        assert_eq!(
            parse(&["--jsonl", "log.jsonl"]).jsonl,
            Some(PathBuf::from("log.jsonl"))
        );
        assert_eq!(
            parse(&["-l", "log.jsonl"]).jsonl,
            Some(PathBuf::from("log.jsonl"))
        );
    }

    #[test]
    fn jsonl_conflicts_with_json_and_directory_paths() {
        assert!(Cli::try_parse_from(["ite", "--jsonl", "a.jsonl", "--json", "b.json"]).is_err());
        assert!(Cli::try_parse_from(["ite", "--jsonl", "a.jsonl", "/some/dir"]).is_err());
    }

    #[test]
    fn no_ignore_flag_and_alias() {
        assert!(parse(&["--no-ignore"]).no_ignore);
        assert!(parse(&["-I"]).no_ignore);
    }

    #[test]
    fn expand_depth_and_all() {
        assert_eq!(parse(&["--expand", "2"]).expand, Some(ExpandSpec::Depth(2)));
        assert_eq!(parse(&["-e", "all"]).expand, Some(ExpandSpec::All));
    }

    #[test]
    fn expand_rejects_garbage() {
        assert!(Cli::try_parse_from(["ite", "--expand", "banana"]).is_err());
    }

    #[test]
    fn repeated_config_flags() {
        let cli = parse(&["-c", "a.toml", "--config", "b.toml"]);
        assert_eq!(
            cli.config,
            vec![PathBuf::from("a.toml"), PathBuf::from("b.toml")]
        );
    }
}