weavatrix-clone 0.1.4

Evidence-first Type-1/2/3 code clone engine with deterministic families and bounded near-miss verification
Documentation
use weavatrix_clone::{CloneConfig, DetectionMode, RepositoryOptions};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutputKind {
    Text,
    Json,
    Sarif,
    BigCloneEval,
}

pub(crate) struct Arguments {
    pub root: String,
    pub summary_only: bool,
    pub min_tokens: usize,
    pub min_lines: usize,
    pub max_lines: usize,
    pub mode: DetectionMode,
    pub output: OutputKind,
    pub cross_extensions: bool,
    pub extensions: Option<Vec<String>>,
}

pub(crate) fn parse_arguments(
    arguments: impl IntoIterator<Item = String>,
) -> Result<Arguments, String> {
    let mut values = arguments.into_iter();
    let mut parsed = Arguments {
        root: ".".to_owned(),
        summary_only: false,
        min_tokens: CloneConfig::default().min_tokens,
        min_lines: RepositoryOptions::default().min_fragment_lines,
        max_lines: RepositoryOptions::default().max_fragment_lines,
        mode: DetectionMode::NearMiss,
        output: OutputKind::Text,
        cross_extensions: false,
        extensions: None,
    };
    let mut root_set = false;
    while let Some(argument) = values.next() {
        match argument.as_str() {
            "--summary" => parsed.summary_only = true,
            "--min-tokens" => parsed.min_tokens = next_usize(&mut values, "--min-tokens")?,
            "--min-lines" => parsed.min_lines = next_usize(&mut values, "--min-lines")?,
            "--max-lines" => parsed.max_lines = next_usize(&mut values, "--max-lines")?,
            "--mode" => parsed.mode = parse_mode(&next_value(&mut values, "--mode")?)?,
            "--output-format" => {
                parsed.output = parse_output(&next_value(&mut values, "--output-format")?)?;
            }
            "--cross-extensions" => parsed.cross_extensions = true,
            "--format" => {
                let value = next_value(&mut values, "--format")?;
                parsed.extensions = Some(value.split(',').map(str::to_owned).collect());
            }
            "--help" | "-h" => return Err(usage()),
            value if value.starts_with('-') => {
                return Err(format!("unknown option {value}\n{}", usage()));
            }
            value if !root_set => {
                value.clone_into(&mut parsed.root);
                root_set = true;
            }
            _ => return Err(format!("only one repository may be scanned\n{}", usage())),
        }
    }
    if parsed.summary_only && parsed.output != OutputKind::Text {
        return Err("--summary is only valid with text output".to_owned());
    }
    Ok(parsed)
}

fn parse_mode(value: &str) -> Result<DetectionMode, String> {
    match value {
        "exact" => Ok(DetectionMode::Exact),
        "renamed" => Ok(DetectionMode::Renamed),
        "near" => Ok(DetectionMode::NearMiss),
        _ => Err("--mode must be exact, renamed, or near".to_owned()),
    }
}

fn parse_output(value: &str) -> Result<OutputKind, String> {
    match value {
        "text" => Ok(OutputKind::Text),
        "json" => Ok(OutputKind::Json),
        "sarif" => Ok(OutputKind::Sarif),
        "bigcloneeval" => Ok(OutputKind::BigCloneEval),
        _ => Err("--output-format must be text, json, sarif, or bigcloneeval".to_owned()),
    }
}

fn next_usize(
    values: &mut impl Iterator<Item = String>,
    option: &'static str,
) -> Result<usize, String> {
    next_value(values, option)?
        .parse()
        .map_err(|_| format!("{option} requires a non-negative integer"))
}

fn next_value(
    values: &mut impl Iterator<Item = String>,
    option: &'static str,
) -> Result<String, String> {
    values
        .next()
        .ok_or_else(|| format!("{option} requires a value"))
}

fn usage() -> String {
    "usage: weavatrix-clone [OPTIONS] [repository]\n\
     options: --summary --mode exact|renamed|near --min-tokens N \
     --min-lines N --max-lines N --format rs,go,... --cross-extensions \
     --output-format text|json|sarif|bigcloneeval"
        .to_owned()
}

#[cfg(test)]
mod tests {
    use super::{OutputKind, parse_arguments};

    #[test]
    fn parses_thresholds_formats_and_output() {
        let parsed = parse_arguments(
            [
                "--min-tokens",
                "32",
                "--min-lines",
                "5",
                "--format",
                "rs,go",
                "--output-format",
                "sarif",
                "repo",
            ]
            .into_iter()
            .map(str::to_owned),
        )
        .expect("valid command line");
        assert_eq!(parsed.min_tokens, 32);
        assert_eq!(parsed.min_lines, 5);
        assert_eq!(parsed.output, OutputKind::Sarif);
        assert_eq!(
            parsed.extensions,
            Some(vec!["rs".to_owned(), "go".to_owned()])
        );
        assert_eq!(parsed.root, "repo");
    }

    #[test]
    fn rejects_invalid_or_conflicting_options() {
        assert!(parse_arguments(["--wat".to_owned()]).is_err());
        assert!(parse_arguments(["--min-lines".to_owned()]).is_err());
        assert!(parse_arguments(["--min-tokens".to_owned(), "x".to_owned()]).is_err());
        assert!(parse_arguments(["--mode".to_owned(), "semantic".to_owned()]).is_err());
        assert!(parse_arguments(["a".to_owned(), "b".to_owned()]).is_err());
        assert!(parse_arguments(["--help".to_owned()]).is_err());
        assert!(
            parse_arguments(
                ["--summary", "--output-format", "json"]
                    .into_iter()
                    .map(str::to_owned)
            )
            .is_err()
        );
    }
}