harn-cli 0.8.93

CLI for the Harn programming language — run, test, REPL, format, and lint
use super::*;

pub(crate) fn check_package_impl(
    anchor: Option<&Path>,
) -> Result<PackageCheckReport, PackageError> {
    let ctx = load_manifest_context_for_anchor(anchor)?;
    let manifest_path = ctx.manifest_path();
    let mut errors = Vec::new();
    let mut warnings = Vec::new();

    let package = ctx.manifest.package.as_ref();
    let name = package.and_then(|package| package.name.clone());
    let version = package.and_then(|package| package.version.clone());
    let package_name = required_package_string(
        package.and_then(|package| package.name.as_deref()),
        "[package].name",
        &mut errors,
    );
    if let Some(name) = package_name {
        if let Err(message) = validate_package_alias(name) {
            push_error(&mut errors, "[package].name", message);
        }
    }
    required_package_string(
        package.and_then(|package| package.version.as_deref()),
        "[package].version",
        &mut errors,
    );
    required_package_string(
        package.and_then(|package| package.description.as_deref()),
        "[package].description",
        &mut errors,
    );
    required_package_string(
        package.and_then(|package| package.license.as_deref()),
        "[package].license",
        &mut errors,
    );
    if !ctx.dir.join("README.md").is_file() {
        push_error(&mut errors, "README.md", "package README.md is required");
    }
    if !ctx.dir.join("LICENSE").is_file() && package.and_then(|p| p.license.as_deref()).is_none() {
        push_error(
            &mut errors,
            "[package].license",
            "publishable packages require a license field or LICENSE file",
        );
    }

    validate_optional_url(
        package.and_then(|package| package.repository.as_deref()),
        "[package].repository",
        &mut errors,
    );
    validate_docs_url(
        &ctx.dir,
        package.and_then(|package| package.docs_url.as_deref()),
        &mut errors,
        &mut warnings,
    );
    match package.and_then(|package| package.harn.as_deref()) {
        Some(range) if supports_current_harn(range) => {}
        Some(range) => push_error(
            &mut errors,
            "[package].harn",
            format!(
                "unsupported Harn version range '{range}'; include the current {} line, for example {}",
                current_harn_line_label(),
                current_harn_range_example()
            ),
        ),
        None => push_error(
            &mut errors,
            "[package].harn",
            format!(
                "missing Harn compatibility metadata; add harn = \"{}\"",
                current_harn_range_example()
            ),
        ),
    }

    validate_dependencies_for_publish(&ctx, &mut errors, &mut warnings);
    if let Err(error) = validate_handoff_routes(&ctx.manifest.handoff_routes, &ctx.manifest) {
        push_error(&mut errors, "handoff_routes", error.to_string());
    }
    validate_rule_pack_for_publish(&ctx, &mut errors);
    let exports = validate_exports_for_publish(&ctx, &mut errors, &mut warnings);
    let (tools, skills) = validate_package_interface_exports(&ctx, &mut errors, &mut warnings);

    Ok(PackageCheckReport {
        package_dir: ctx.dir.display().to_string(),
        manifest_path: manifest_path.display().to_string(),
        name,
        version,
        errors,
        warnings,
        exports,
        tools,
        skills,
    })
}

pub(super) fn validate_rule_pack_for_publish(
    ctx: &ManifestContext,
    errors: &mut Vec<PackageCheckDiagnostic>,
) {
    if ctx.manifest.rules.rule_dirs.is_empty() {
        return;
    }
    if let Err(error) = collect_rule_pack_metadata(ctx) {
        push_error(errors, "[rules] ruleDirs", error.to_string());
    }
}

pub(crate) fn collect_rule_pack_metadata(
    ctx: &ManifestContext,
) -> Result<Option<RegistryRulePackInfo>, PackageError> {
    if ctx.manifest.rules.rule_dirs.is_empty() {
        return Ok(None);
    }

    let mut rule_count = 0usize;
    let mut languages = BTreeSet::new();
    let mut safety = BTreeMap::<String, usize>::new();
    for rel in &ctx.manifest.rules.rule_dirs {
        let dir = safe_package_relative_path(&ctx.dir, rel)?;
        if !dir.is_dir() {
            return Err(PackageError::Validation(format!(
                "`[rules] ruleDirs` entry `{rel}` is not a directory ({})",
                dir.display()
            )));
        }
        let mut paths: Vec<_> = fs::read_dir(&dir)
            .map_err(|error| format!("failed to read rule dir {}: {error}", dir.display()))?
            .filter_map(Result::ok)
            .map(|entry| entry.path())
            .filter(|path| {
                path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("toml")
            })
            .collect();
        paths.sort();
        for path in paths {
            let rule = read_rule_file_metadata(&path)?;
            rule_count += 1;
            languages.insert(rule.language);
            *safety.entry(rule.safety).or_default() += 1;
        }
    }

    if rule_count == 0 {
        return Err(PackageError::Validation(
            "rule packs must contain at least one `*.toml` rule under `[rules] ruleDirs`"
                .to_string(),
        ));
    }

    Ok(Some(RegistryRulePackInfo {
        rule_count,
        languages: languages.into_iter().collect(),
        safety_summary: safety
            .into_iter()
            .map(|(name, count)| format!("{name}:{count}"))
            .collect(),
    }))
}

pub(super) struct RuleFileMetadata {
    pub(super) language: String,
    pub(super) safety: String,
}

#[cfg(feature = "hostlib")]
pub(super) fn read_rule_file_metadata(path: &Path) -> Result<RuleFileMetadata, PackageError> {
    let source = fs::read_to_string(path)
        .map_err(|error| format!("failed to read rule {}: {error}", path.display()))?;
    let rule = harn_rules::Rule::from_toml_str(&source)
        .map_err(|error| format!("failed to parse rule {}: {error}", path.display()))?;
    let safety = if rule.fix.is_some() {
        rule.safety.as_str()
    } else {
        "no-fix"
    };
    Ok(RuleFileMetadata {
        language: rule.language,
        safety: safety.to_string(),
    })
}

#[cfg(not(feature = "hostlib"))]
pub(super) fn read_rule_file_metadata(path: &Path) -> Result<RuleFileMetadata, PackageError> {
    #[derive(Deserialize)]
    struct MinimalRule {
        id: String,
        language: String,
        #[serde(default)]
        safety: Option<String>,
        #[serde(default)]
        fix: Option<String>,
        rule: toml::Value,
    }

    let source = fs::read_to_string(path)
        .map_err(|error| format!("failed to read rule {}: {error}", path.display()))?;
    let rule: MinimalRule = toml::from_str(&source)
        .map_err(|error| format!("failed to parse rule {}: {error}", path.display()))?;
    if rule.id.trim().is_empty() || rule.language.trim().is_empty() {
        return Err(PackageError::Validation(format!(
            "rule {} must declare non-empty `id` and `language`",
            path.display()
        )));
    }
    let safety = if rule.fix.is_some() {
        rule.safety.unwrap_or_else(|| "scope-local".to_string())
    } else {
        "no-fix".to_string()
    };
    let _ = rule.rule;
    Ok(RuleFileMetadata {
        language: rule.language,
        safety,
    })
}