mars-agents 0.10.2

Agent package manager for .agents/ directories
Documentation
//! Shared lowering lossiness types and summarized diagnostics.
//!
//! Lowerers record per-field [`LossyField`] entries; callers aggregate by
//! `(item, target)` before emitting so re-sync does not spam one line per field.
//!
//! **Consequence tiers:** target-enforced losses (`Dropped`, `Approximate`) warn loudly
//! in default `Surface` mode. Launch-time fields enforced by Meridian at spawn
//! (`MeridianOnly`) roll into one summary line unless `--verbose` is set.

use std::collections::BTreeMap;

use crate::diagnostic::{DiagnosticCategory, DiagnosticCollector};

/// A field that was dropped or only approximately lowered in the native artifact.
#[derive(Debug, Clone)]
pub struct LossyField {
    pub field: String,
    pub target: String,
    pub classification: Lossiness,
}

/// Lossiness classification for a single field in a target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lossiness {
    Approximate { note: &'static str },
    Dropped,
    MeridianOnly,
}

/// A secondary artifact emitted alongside the primary lowered file (e.g. Codex `openai.yaml`).
#[derive(Debug, Clone)]
pub struct LoweredSibling {
    /// Path relative to the skill directory root (e.g. `openai.yaml`).
    pub rel_path: String,
    pub bytes: Vec<u8>,
}

/// Output from a single lowering pass.
pub struct LoweredOutput {
    /// Serialized bytes for the native artifact.
    pub bytes: Vec<u8>,
    /// Lossiness findings for fields that were dropped or approximated.
    pub lossy_fields: Vec<LossyField>,
    /// Extra files written next to the primary artifact (skills only today).
    pub siblings: Vec<LoweredSibling>,
}

fn target_label(target: &str) -> String {
    format!(".{}", target.to_lowercase())
}

fn summarize_fields(fields: &[&str]) -> String {
    fields.join(", ")
}

/// Emit deduplicated lossiness warnings for one lowered agent artifact.
pub fn emit_agent_lossiness_warnings(
    agent_name: &str,
    lossy_fields: &[LossyField],
    diag: &mut DiagnosticCollector,
) {
    emit_item_lossiness_warnings(
        "agent",
        agent_name,
        "agent-field-dropped",
        "agent-field-meridian-only",
        "agent-field-approximate",
        lossy_fields,
        diag,
    );
}

/// Emit deduplicated lossiness warnings for one lowered skill artifact.
pub fn emit_skill_lossiness_warnings(
    skill_name: &str,
    lossy_fields: &[LossyField],
    diag: &mut DiagnosticCollector,
) {
    emit_item_lossiness_warnings(
        "skill",
        skill_name,
        "skill-field-dropped",
        "skill-field-meridian-only",
        "skill-field-approximate",
        lossy_fields,
        diag,
    );
}

fn emit_item_lossiness_warnings(
    item_kind: &str,
    item_name: &str,
    dropped_code: &'static str,
    _meridian_code: &'static str,
    approximate_code: &'static str,
    lossy_fields: &[LossyField],
    diag: &mut DiagnosticCollector,
) {
    let mut dropped_by_target: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut meridian_by_target: BTreeMap<String, Vec<String>> = BTreeMap::new();

    for lf in lossy_fields {
        match &lf.classification {
            Lossiness::Dropped => {
                dropped_by_target
                    .entry(lf.target.clone())
                    .or_default()
                    .push(lf.field.clone());
            }
            Lossiness::MeridianOnly => {
                meridian_by_target
                    .entry(lf.target.clone())
                    .or_default()
                    .push(lf.field.clone());
            }
            Lossiness::Approximate { note } => {
                diag.warn_with_category(
                    approximate_code,
                    format!(
                        "{item_kind} `{item_name}`: field `{}` approximately mapped in {} ({note})",
                        lf.field, lf.target
                    ),
                    DiagnosticCategory::Lossiness,
                );
            }
        }
    }

    emit_grouped_warnings(
        item_kind,
        item_name,
        dropped_code,
        "dropped",
        dropped_by_target,
        diag,
    );
    for (target, fields) in meridian_by_target {
        for field in fields {
            diag.record_meridian_only_field(item_kind, item_name, &target, &field);
        }
    }
}

fn emit_grouped_warnings(
    item_kind: &str,
    item_name: &str,
    code: &'static str,
    classification_label: &str,
    grouped: BTreeMap<String, Vec<String>>,
    diag: &mut DiagnosticCollector,
) {
    for (target, mut fields) in grouped {
        fields.sort();
        fields.dedup();
        let field_refs: Vec<&str> = fields.iter().map(String::as_str).collect();
        let count = field_refs.len();
        let noun = if count == 1 { "field" } else { "fields" };
        diag.warn_with_category(
            code,
            format!(
                "{item_kind} `{item_name}`: {count} {noun} {classification_label} for {} ({})",
                target_label(&target),
                summarize_fields(&field_refs)
            ),
            DiagnosticCategory::Lossiness,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diagnostic::{DiagnosticLevel, LossinessMode};

    #[test]
    fn multi_field_drop_produces_one_summarized_warning_per_target() {
        let lossy = vec![
            LossyField {
                field: "disallowed-tools".into(),
                target: "OpenCode".into(),
                classification: Lossiness::Dropped,
            },
            LossyField {
                field: "user-invocable".into(),
                target: "OpenCode".into(),
                classification: Lossiness::Dropped,
            },
        ];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Surface);
        emit_skill_lossiness_warnings("planning", &lossy, &mut diag);
        let warnings: Vec<_> = diag
            .drain()
            .into_iter()
            .filter(|d| d.level == DiagnosticLevel::Warning)
            .collect();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "skill-field-dropped");
        assert_eq!(warnings[0].category, Some(DiagnosticCategory::Lossiness));
        assert!(warnings[0].message.contains("skill `planning`"));
        assert!(
            warnings[0]
                .message
                .contains("2 fields dropped for .opencode")
        );
        assert!(warnings[0].message.contains("disallowed-tools"));
        assert!(warnings[0].message.contains("user-invocable"));
    }

    #[test]
    fn repeated_emit_on_resync_still_one_warning_per_target_not_per_field() {
        let lossy = vec![
            LossyField {
                field: "model-invocable".into(),
                target: "Claude".into(),
                classification: Lossiness::Dropped,
            },
            LossyField {
                field: "user-invocable".into(),
                target: "Claude".into(),
                classification: Lossiness::Dropped,
            },
        ];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Surface);
        for _ in 0..2 {
            emit_agent_lossiness_warnings("coder", &lossy, &mut diag);
        }
        let dropped: Vec<_> = diag
            .drain()
            .into_iter()
            .filter(|d| d.code == "agent-field-dropped")
            .collect();
        assert_eq!(dropped.len(), 2, "each lowering pass emits one summary");
        assert!(
            dropped
                .iter()
                .all(|d| d.message.contains("2 fields dropped for .claude"))
        );
    }

    #[test]
    fn approximate_warnings_remain_per_field() {
        let lossy = vec![LossyField {
            field: "tools".into(),
            target: "Claude".into(),
            classification: Lossiness::Approximate {
                note: "unknown tool name passed through verbatim",
            },
        }];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Surface);
        emit_agent_lossiness_warnings("coder", &lossy, &mut diag);
        let warnings = diag.drain();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "agent-field-approximate");
    }

    #[test]
    fn meridian_only_surface_emits_summary_not_per_item() {
        let lossy = vec![LossyField {
            field: "approval".into(),
            target: "Claude".into(),
            classification: Lossiness::MeridianOnly,
        }];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Surface);
        emit_agent_lossiness_warnings("coder", &lossy, &mut diag);
        let warnings = diag.drain();
        assert!(
            !warnings
                .iter()
                .any(|d| d.code == "agent-field-meridian-only"),
            "surface must not emit per-item meridian-only warnings"
        );
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "launch-time-field-summary");
        assert!(
            warnings[0]
                .message
                .contains("launch-time field mapping handled by meridian at spawn")
        );
    }

    #[test]
    fn meridian_only_verbose_emits_per_item_detail() {
        let lossy = vec![
            LossyField {
                field: "approval".into(),
                target: "Claude".into(),
                classification: Lossiness::MeridianOnly,
            },
            LossyField {
                field: "sandbox".into(),
                target: "Claude".into(),
                classification: Lossiness::MeridianOnly,
            },
        ];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Verbose);
        emit_agent_lossiness_warnings("coder", &lossy, &mut diag);
        let warnings = diag.drain();
        assert!(
            !warnings
                .iter()
                .any(|d| d.code == "launch-time-field-summary"),
            "verbose must not emit summary line"
        );
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "agent-field-meridian-only");
        assert!(warnings[0].message.contains("approval"));
        assert!(warnings[0].message.contains("sandbox"));
    }

    #[test]
    fn dropped_warnings_stay_loud_in_surface_mode() {
        let lossy = vec![LossyField {
            field: "user-invocable".into(),
            target: "Claude".into(),
            classification: Lossiness::Dropped,
        }];
        let mut diag = DiagnosticCollector::with_lossiness_mode(LossinessMode::Surface);
        emit_skill_lossiness_warnings("planning", &lossy, &mut diag);
        let warnings = diag.drain();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "skill-field-dropped");
    }
}