injm 1.0.1

A CLI tool that injects content into marked regions in source files.
use super::Result;
use crate::{parser::ParserError, types::MarkerConfig};

pub(super) fn extract_config(comment: &str) -> Result<MarkerConfig> {
    let mut config = MarkerConfig::default();
    for opt in comment.split_whitespace().filter(|c| c.starts_with(":")) {
        extract_value(opt, &mut config)?;
    }

    Ok(config)
}

fn extract_value(opt: &str, config: &mut MarkerConfig) -> Result<()> {
    let name = extract_name(opt)?;

    match name.as_str() {
        "trim" => config.trim = extract_bool(opt, true)?,
        "offset" => config.offset = extract_int(opt)?,
        "indent" => config.indent = Some(extract_int(opt)?),
        _ => {
            return Err(ParserError::InvalidOption {
                opt: opt.to_string(),
            });
        }
    }

    Ok(())
}

fn extract_int(opt: &str) -> Result<usize> {
    let (_, value) = opt
        .split_once("=")
        .ok_or(ParserError::InvalidIntOptionFormat {
            opt: opt.to_string(),
        })?;

    Ok(value.parse::<usize>()?)
}

fn extract_bool(opt: &str, flag_value: bool) -> Result<bool> {
    let Some(value) = opt
        .split_once('=')
        .map(|(_, value)| value.trim().to_lowercase())
    else {
        return Ok(flag_value);
    };

    Ok(value.parse::<bool>()?)
}

fn extract_name(opt: &str) -> Result<String> {
    let rest = opt
        .strip_prefix(":")
        .ok_or(ParserError::InvalidOptionNameFormat {
            opt: opt.to_string(),
        })?;

    if rest.is_empty() {
        return Err(ParserError::MissingOptionName {
            opt: opt.to_string(),
        });
    }

    let end = rest
        .find(|c: char| !c.is_ascii_alphabetic())
        .unwrap_or(rest.len());

    let name = &rest[..end].to_lowercase();

    Ok(name.to_string())
}

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

    fn default_config() -> MarkerConfig {
        MarkerConfig::default()
    }

    #[test]
    fn test_default_config_when_no_options() {
        let config = extract_config("injm begin <hello").unwrap();
        assert_eq!(config, default_config());
    }

    #[test]
    fn test_offset_option() {
        let config = extract_config("injm begin <hello :offset=1").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                offset: 1,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_offset_zero_is_default() {
        let config = extract_config("injm begin <hello :offset=0").unwrap();
        assert_eq!(config, default_config());
    }

    #[test]
    fn test_offset_multiple_digits() {
        let config = extract_config("injm begin <hello :offset=12").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                offset: 12,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_trim_option() {
        let config = extract_config("injm begin <hello :trim=true").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: true,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_trim_as_flag_without_value() {
        let config = extract_config("injm begin <hello :trim").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: true,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_indent_option() {
        let config = extract_config("injm begin <hello :indent=4").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                indent: Some(4),
                ..default_config()
            }
        );
    }

    #[test]
    fn test_multiple_options() {
        let config = extract_config("injm begin <hello :trim=true :offset=2 :indent=4").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                offset: 2,
                trim: true,
                indent: Some(4),
            }
        );
    }

    #[test]
    fn test_options_in_any_order() {
        let config = extract_config("injm begin <hello :indent=4 :offset=2").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                offset: 2,
                indent: Some(4),
                ..default_config()
            }
        );
    }

    #[test]
    fn test_options_with_output_marker() {
        let config = extract_config("injm begin >id :trim=true").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: true,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_option_names_are_case_insensitive() {
        let config = extract_config("injm begin <hello :TRIM=true").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: true,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_multiple_same_option_last_wins() {
        let config = extract_config("injm begin <hello :offset=1 :offset=3").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                offset: 3,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_id_is_not_parsed_as_option() {
        let config = extract_config("injm begin <hello").unwrap();
        assert_eq!(config, default_config());
    }

    #[test]
    fn test_unknown_option_errors() {
        let err = extract_config("injm begin <hello :unknown=1").unwrap_err();
        assert!(matches!(err, ParserError::InvalidOption { .. }));
    }

    #[test]
    fn test_missing_option_name_errors() {
        let err = extract_config("injm begin <hello :").unwrap_err();
        assert!(matches!(err, ParserError::MissingOptionName { .. }));
    }

    #[test]
    fn test_int_option_without_equals_errors() {
        let err = extract_config("injm begin <hello :offset").unwrap_err();
        assert!(matches!(err, ParserError::InvalidIntOptionFormat { .. }));
    }

    #[test]
    fn test_int_option_with_non_integer_errors() {
        let err = extract_config("injm begin <hello :offset=abc").unwrap_err();
        assert!(matches!(err, ParserError::ParseIntError(_)));
    }

    #[test]
    fn test_int_option_with_trailing_text_errors() {
        let err = extract_config("injm begin <hello :offset=1x").unwrap_err();
        assert!(matches!(err, ParserError::ParseIntError(_)));
    }

    #[test]
    fn test_indent_without_value_errors() {
        let err = extract_config("injm begin <hello :indent").unwrap_err();
        assert!(matches!(err, ParserError::InvalidIntOptionFormat { .. }));
    }

    #[test]
    fn test_unknown_option_does_not_apply_previous() {
        let err = extract_config("injm begin <hello :offset=1 :unknown=2").unwrap_err();
        assert!(matches!(err, ParserError::InvalidOption { .. }));
    }

    #[test]
    fn test_trim_false() {
        let config = extract_config("injm begin <hello :trim=false").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: false,
                ..default_config()
            }
        );
    }

    #[test]
    fn test_trim_value_is_case_insensitive() {
        let config = extract_config("injm begin <hello :trim=TRUE").unwrap();
        assert_eq!(
            config,
            MarkerConfig {
                trim: true,
                ..default_config()
            }
        );

        let config = extract_config("injm begin <hello :trim=False").unwrap();
        assert_eq!(config, default_config());
    }

    #[test]
    fn test_trim_value_with_internal_whitespace_is_split() {
        let err = extract_config("injm begin <hello :trim= true").unwrap_err();
        assert!(matches!(err, ParserError::ParseBoolError(_)));
    }

    #[test]
    fn test_trim_invalid_value_errors() {
        for bad in [":trim=1", ":trim=yes", ":trim=on", ":trim=abc", ":trim="] {
            let opt = format!("injm begin <hello {bad}");
            let err = extract_config(&opt).unwrap_err();
            assert!(matches!(err, ParserError::ParseBoolError(_)), "{bad}");
        }
    }

    #[test]
    fn test_trim_repeated_last_wins() {
        let config = extract_config("injm begin <hello :trim=true :trim=false").unwrap();
        assert_eq!(config, default_config());
    }
}