osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use crate::completion::CommandLineParser;
use crate::config::ResolvedConfig;
use crate::dsl::parse::lexer::split_pipeline;
use crate::repl::{LineProjection, LineProjector};
use miette::{IntoDiagnostic, Result, WrapErr};
use std::collections::BTreeSet;
use std::sync::Arc;

use crate::app::ReplScopeStack;
use crate::app::{CMD_HELP, REPL_SHELLABLE_COMMANDS};
use crate::cli::invocation::{hidden_invocation_completion_flags, scan_command_tokens_with_trace};
use crate::cli::pipeline::{ParsedCommandLine, parse_command_text_with_aliases};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReplParsedLine {
    pub(crate) command_tokens: Vec<String>,
    pub(crate) dispatch_tokens: Vec<String>,
    pub(crate) stages: Vec<String>,
}

impl ReplParsedLine {
    pub(crate) fn parse(line: &str, config: &ResolvedConfig) -> Result<Self> {
        let parsed = parse_command_text_with_aliases(line, config).wrap_err_with(|| {
            format!(
                "failed to parse REPL line: {}",
                crate::cli::pipeline::truncate_display(line, 60)
            )
        })?;
        Ok(Self::from_parsed(parsed))
    }

    pub(crate) fn requests_repl_help(&self) -> bool {
        self.command_tokens.len() == 1 && matches!(self.command_tokens[0].as_str(), "--help" | "-h")
    }

    pub(crate) fn shell_entry_command<'a>(&'a self, scope: &ReplScopeStack) -> Option<&'a str> {
        if !self.stages.is_empty() || self.dispatch_tokens.len() != 1 {
            return None;
        }

        let command = self.dispatch_tokens[0].trim();
        if command.is_empty()
            || !is_repl_shellable_command(command)
            || scope.contains_command(command)
        {
            return None;
        }

        Some(command)
    }

    pub(crate) fn prefixed_tokens(&self, scope: &ReplScopeStack) -> Vec<String> {
        scope.prefixed_tokens(&self.dispatch_tokens)
    }

    fn from_parsed(parsed: ParsedCommandLine) -> Self {
        let command_tokens = parsed.tokens;
        let dispatch_tokens =
            rewrite_help_alias_tokens(&command_tokens).unwrap_or_else(|| command_tokens.clone());

        Self {
            command_tokens,
            dispatch_tokens,
            stages: parsed.stages,
        }
    }
}

pub(crate) fn rewrite_help_alias_tokens(tokens: &[String]) -> Option<Vec<String>> {
    rewrite_help_alias_tokens_at(tokens, 0)
}

pub(crate) fn rewrite_help_alias_tokens_at(
    tokens: &[String],
    command_index: usize,
) -> Option<Vec<String>> {
    if tokens.get(command_index).map(String::as_str) != Some(CMD_HELP)
        || !has_valid_help_alias_target(tokens, command_index)
    {
        return None;
    }

    let mut rewritten = tokens[..command_index].to_vec();
    rewritten.extend_from_slice(&tokens[command_index + 1..]);
    if !rewritten.iter().any(|arg| arg == "--help" || arg == "-h") {
        rewritten.push("--help".to_string());
    }
    Some(rewritten)
}

pub(crate) fn project_repl_ui_line(line: &str, config: &ResolvedConfig) -> Result<LineProjection> {
    let _ = config;
    split_pipeline(line).into_diagnostic().wrap_err_with(|| {
        format!(
            "failed to parse REPL line: {}",
            crate::cli::pipeline::truncate_display(line, 60)
        )
    })?;
    let parser = CommandLineParser;
    let spans = parser.tokenize_with_spans(line);
    if spans.is_empty() {
        return Ok(LineProjection::passthrough(line)
            .with_hidden_suggestions(hidden_invocation_completion_flags(&Default::default())));
    }

    let tokens = spans
        .iter()
        .map(|span| span.value.clone())
        .collect::<Vec<_>>();
    let scanned = scan_command_tokens_with_trace(&tokens)?;
    let mut projected = line.as_bytes().to_vec();

    for (index, span) in spans.iter().enumerate() {
        if scanned.kept_indices.contains(&index) {
            continue;
        }
        blank_bytes(&mut projected, span.start, span.end);
    }

    if scanned.tokens.first().map(String::as_str) == Some(CMD_HELP)
        && scanned.tokens.len() > 1
        && let Some(help_index) = scanned.kept_indices.first().copied()
        && let Some(span) = spans.get(help_index)
    {
        blank_bytes(&mut projected, span.start, span.end);
    }

    let hidden_suggestions = projection_hidden_suggestions(&scanned.tokens, &scanned.invocation);

    Ok(LineProjection::passthrough(
        String::from_utf8(projected).unwrap_or_else(|_| line.to_string()),
    )
    .with_hidden_suggestions(hidden_suggestions))
}

pub(crate) fn build_repl_ui_line_projector(config: &ResolvedConfig) -> LineProjector {
    let config = config.clone();
    Arc::new(move |line| {
        project_repl_ui_line(line, &config).unwrap_or_else(|_| LineProjection::passthrough(line))
    })
}

pub(crate) fn help_alias_target_at(tokens: &[String], command_index: usize) -> Option<&str> {
    tokens.get(command_index + 1).map(String::as_str)
}

pub(crate) fn has_valid_help_alias_target(tokens: &[String], command_index: usize) -> bool {
    matches!(
        help_alias_target_at(tokens, command_index),
        Some(target) if !target.is_empty() && !target.starts_with('-') && target != CMD_HELP
    )
}

fn projection_hidden_suggestions(
    tokens: &[String],
    invocation: &crate::cli::invocation::InvocationOptions,
) -> BTreeSet<String> {
    if tokens.first().map(String::as_str) != Some(CMD_HELP) {
        return hidden_invocation_completion_flags(invocation);
    }

    let mut hidden = hidden_invocation_completion_flags(&Default::default());
    hidden.insert(CMD_HELP.to_string());
    if has_valid_help_alias_target(tokens, 0) {
        hidden.remove("--verbose");
        if invocation.verbose > 0 {
            hidden.insert("--verbose".to_string());
        }
    }

    hidden
}

fn blank_bytes(buffer: &mut [u8], start: usize, end: usize) {
    for byte in buffer.get_mut(start..end).into_iter().flatten() {
        *byte = b' ';
    }
}

pub(crate) fn is_repl_shellable_command(command: &str) -> bool {
    REPL_SHELLABLE_COMMANDS
        .iter()
        .any(|candidate| candidate.eq_ignore_ascii_case(command.trim()))
}

#[cfg(test)]
mod tests {
    use super::{
        ReplParsedLine, build_repl_ui_line_projector, has_valid_help_alias_target,
        project_repl_ui_line, rewrite_help_alias_tokens_at,
    };
    use crate::app::ReplScopeStack;
    use crate::config::{ConfigLayer, ConfigResolver, ResolveOptions};

    fn make_config() -> crate::config::ResolvedConfig {
        let mut defaults = ConfigLayer::default();
        defaults.set("profile.default", "default");

        let mut resolver = ConfigResolver::default();
        resolver.set_defaults(defaults);
        resolver
            .resolve(ResolveOptions::default().with_terminal("repl"))
            .expect("config should resolve")
    }

    #[test]
    fn help_alias_parsing_rewrite_and_shell_entry_rules_cover_valid_and_invalid_cases_unit() {
        let config = make_config();
        let parsed =
            ReplParsedLine::parse("help ldap user", &config).expect("help alias should parse");
        assert_eq!(parsed.command_tokens, vec!["help", "ldap", "user"]);
        assert_eq!(parsed.dispatch_tokens, vec!["ldap", "user", "--help"]);

        let rewritten = rewrite_help_alias_tokens_at(
            &["orch".to_string(), "help".to_string(), "status".to_string()],
            1,
        )
        .expect("help alias should rewrite after a scope prefix");
        assert_eq!(rewritten, vec!["orch", "status", "--help"]);

        for invalid in [
            vec!["help".to_string(), "help".to_string()],
            vec!["help".to_string(), "--help".to_string()],
        ] {
            assert!(rewrite_help_alias_tokens_at(&invalid, 0).is_none());
            assert!(!has_valid_help_alias_target(&invalid, 0));
        }

        let mut scope = ReplScopeStack::default();
        let ldap = ReplParsedLine::parse("ldap", &config).expect("shell should parse");
        assert_eq!(ldap.shell_entry_command(&scope), Some("ldap"));

        scope.enter("ldap");
        assert_eq!(ldap.shell_entry_command(&scope), None);

        let help_alias =
            ReplParsedLine::parse("help ldap", &config).expect("help alias should parse");
        assert_eq!(help_alias.shell_entry_command(&scope), None);
    }

    #[test]
    fn project_repl_ui_line_masks_invocation_tokens_while_preserving_visible_targets_unit() {
        let config = make_config();

        let projected = project_repl_ui_line("--json help ldap user", &config)
            .expect("projection should succeed");
        assert!(projected.line.contains("ldap user"));
        assert!(!projected.line.contains("--json"));
        assert!(!projected.line.contains("help"));
        assert_eq!(projected.line.len(), "--json help ldap user".len());

        let empty = project_repl_ui_line("", &config).expect("projection should succeed");
        assert_eq!(empty.line, "");
        assert!(empty.hidden_suggestions.contains("--json"));

        for (line, visible_fragment) in [("help history", "history"), ("help his", "his")] {
            let projected = project_repl_ui_line(line, &config).expect("projection should succeed");
            assert!(!projected.line.contains("help"));
            assert!(projected.line.contains(visible_fragment), "line: {line}");
        }
    }

    #[test]
    fn project_repl_ui_line_hidden_suggestions_follow_help_verbosity_and_used_flags_unit() {
        let config = make_config();

        let cases = [
            ("history -", vec!["--json", "--debug"], Vec::<&str>::new()),
            (
                "-v history -",
                Vec::<&str>::new(),
                vec!["--json", "--debug"],
            ),
            (
                "-v --json history -",
                vec!["--json", "--format", "--table"],
                vec!["--debug"],
            ),
            (
                "help ",
                vec!["help", "--json", "--debug"],
                Vec::<&str>::new(),
            ),
            (
                "help history -",
                vec!["--json", "--debug"],
                vec!["--verbose"],
            ),
        ];

        for (line, hidden, visible) in cases {
            let projected = project_repl_ui_line(line, &config).expect("projection should succeed");

            for suggestion in hidden {
                assert!(
                    projected.hidden_suggestions.contains(suggestion),
                    "line: {line}, expected hidden: {suggestion}"
                );
            }
            for suggestion in visible {
                assert!(
                    !projected.hidden_suggestions.contains(suggestion),
                    "line: {line}, expected visible: {suggestion}"
                );
            }
        }
    }

    #[test]
    fn build_repl_ui_line_projector_falls_back_to_passthrough_on_parse_error_unit() {
        let config = make_config();
        let projector = build_repl_ui_line_projector(&config);
        let projected = projector("config \"unterminated");

        assert_eq!(projected.line, "config \"unterminated");
    }
}