duet 0.8.7

bi-directional synchronization
use std::fmt;
use std::path::PathBuf;

use color_eyre::eyre::Report;
use essrpc::RPCError;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuredSyncError {
    pub version: u8,
    pub side: String,
    pub operation: String,
    pub path: Option<PathBuf>,
    pub kind: String,
    pub sources: Vec<String>,
    pub message: String,
}

impl StructuredSyncError {
    pub fn new(
        side: impl Into<String>,
        operation: impl Into<String>,
        path: Option<PathBuf>,
        error: impl fmt::Debug,
    ) -> Self {
        let message = format!("{:?}", error);
        Self::from_message(side, operation, path, message)
    }

    pub fn from_message(
        side: impl Into<String>,
        operation: impl Into<String>,
        path: Option<PathBuf>,
        message: impl Into<String>,
    ) -> Self {
        Self::from_message_and_sources(side, operation, path, message, Vec::new())
    }

    fn from_message_and_sources(
        side: impl Into<String>,
        operation: impl Into<String>,
        path: Option<PathBuf>,
        message: impl Into<String>,
        sources: Vec<String>,
    ) -> Self {
        let message = message.into();
        let classification_text = if sources.is_empty() {
            message.clone()
        } else {
            format!("{}\n{}", message, sources.join("\n"))
        };
        Self {
            version: 1,
            side: side.into(),
            operation: operation.into(),
            path,
            kind: classify_error_message(&classification_text).to_string(),
            sources,
            message,
        }
    }

    pub fn remote(operation: &str, path: Option<PathBuf>, error: impl fmt::Debug) -> Self {
        Self::new("remote", operation, path, error)
    }

    pub fn from_report(
        side: impl Into<String>,
        operation: impl Into<String>,
        path: Option<PathBuf>,
        report: Report,
    ) -> Self {
        let message = report.to_string();
        let sources = report.chain().skip(1).map(ToString::to_string).collect();
        Self::from_message_and_sources(side, operation, path, message, sources)
    }

    pub fn parse(message: &str) -> Option<Self> {
        let mut lines = message.lines();
        let version = lines
            .next()?
            .strip_prefix("duet-sync-error-v")?
            .parse()
            .ok()?;
        let mut side = None;
        let mut operation = None;
        let mut path = None;
        let mut kind = None;
        let mut sources = Vec::new();
        let mut error_message = None;

        while let Some(line) = lines.next() {
            if let Some(value) = line.strip_prefix("side: ") {
                side = Some(value.to_string());
            } else if let Some(value) = line.strip_prefix("operation: ") {
                operation = Some(value.to_string());
            } else if let Some(value) = line.strip_prefix("path: ") {
                path = Some(PathBuf::from(value));
            } else if let Some(value) = line.strip_prefix("kind: ") {
                kind = Some(value.to_string());
            } else if let Some(value) = line.strip_prefix("source: ") {
                sources.push(value.to_string());
            } else if let Some(value) = line.strip_prefix("message: ") {
                let mut full_message = value.to_string();
                for continuation in lines {
                    full_message.push('\n');
                    full_message.push_str(continuation);
                }
                error_message = Some(full_message);
                break;
            }
        }

        Some(Self {
            version,
            side: side?,
            operation: operation?,
            path,
            kind: kind?,
            sources,
            message: error_message?,
        })
    }

    pub fn render_for_user(&self) -> String {
        let mut rendered = format!(
            "{} {} failed",
            escape_control_chars(&self.side),
            escape_control_chars(&self.operation)
        );
        if let Some(path) = &self.path {
            rendered.push_str(&format!(" at {}", display_path(path)));
        }
        if self.kind != "other" {
            rendered.push_str(&format!(" ({})", escape_control_chars(&self.kind)));
        }
        if let Some(summary) = first_error_line(&self.message) {
            rendered.push_str(&format!(": {}", escape_control_chars(summary)));
        }
        if let Some(source) = self.sources.first() {
            rendered.push_str(&format!("; caused by: {}", escape_control_chars(source)));
        }
        if let Some(recovery) = recovery_line(&self.message) {
            rendered.push('\n');
            rendered.push_str(&escape_control_chars(recovery));
        }
        rendered
    }
}

impl fmt::Display for StructuredSyncError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "duet-sync-error-v{}", self.version)?;
        writeln!(f, "side: {}", single_line(&self.side))?;
        writeln!(f, "operation: {}", single_line(&self.operation))?;
        if let Some(path) = &self.path {
            writeln!(f, "path: {}", single_line(&path.display().to_string()))?;
        }
        writeln!(f, "kind: {}", single_line(&self.kind))?;
        for source in &self.sources {
            writeln!(f, "source: {}", single_line(source))?;
        }
        write!(f, "message: {}", self.message)
    }
}

pub fn render_rpc_error(error: &RPCError) -> String {
    let message = error.to_string();
    if let Some(sync_error) = StructuredSyncError::parse(&message) {
        sync_error.render_for_user()
    } else {
        format!("{:?}", error)
    }
}

pub fn render_error(
    side: impl Into<String>,
    operation: impl Into<String>,
    path: Option<PathBuf>,
    error: impl fmt::Debug,
) -> String {
    StructuredSyncError::new(side, operation, path, error).render_for_user()
}

pub fn render_report(
    side: impl Into<String>,
    operation: impl Into<String>,
    path: Option<PathBuf>,
    report: Report,
) -> String {
    StructuredSyncError::from_report(side, operation, path, report).render_for_user()
}

pub fn render_message(
    side: impl Into<String>,
    operation: impl Into<String>,
    path: Option<PathBuf>,
    message: impl Into<String>,
) -> String {
    StructuredSyncError::from_message(side, operation, path, message).render_for_user()
}

fn classify_error_message(message: &str) -> &'static str {
    let message = message.to_lowercase();
    if message.contains("permission denied")
        || message.contains("permissiondenied")
        || message.contains("os error 13")
    {
        "permission_denied"
    } else if message.contains("no such file or directory") || message.contains("os error 2") {
        "not_found"
    } else if message.contains("not a directory") || message.contains("os error 20") {
        "not_directory"
    } else {
        "other"
    }
}

fn single_line(value: &str) -> String {
    escape_control_chars(value)
}

fn display_path(path: &PathBuf) -> String {
    escape_control_chars(&path.display().to_string())
}

fn escape_control_chars(value: &str) -> String {
    let mut escaped = String::new();
    for c in value.chars() {
        if c.is_control() {
            escaped.extend(c.escape_default());
        } else {
            escaped.push(c);
        }
    }
    escaped
}

fn first_error_line(message: &str) -> Option<&str> {
    message.lines().map(str::trim).find(|line| !line.is_empty())
}

fn recovery_line(message: &str) -> Option<&str> {
    message.lines().find_map(|line| {
        let line = line.trim();
        line.find("Recovery:").map(|index| &line[index..])
    })
}

#[cfg(test)]
mod tests {
    use std::io;

    use color_eyre::eyre::{eyre, WrapErr};
    use essrpc::{RPCError, RPCErrorKind};

    use super::*;

    #[test]
    fn structured_sync_error_formats_permission_context() {
        let error = StructuredSyncError::remote(
            "save remote state",
            Some(PathBuf::from("state.snp")),
            io::Error::from(io::ErrorKind::PermissionDenied),
        );
        let formatted = error.to_string();

        assert_eq!(error.side, "remote");
        assert_eq!(error.operation, "save remote state");
        assert_eq!(error.path, Some(PathBuf::from("state.snp")));
        assert_eq!(error.kind, "permission_denied");
        assert!(formatted.contains("duet-sync-error-v1"));
        assert!(formatted.contains("side: remote"));
        assert!(formatted.contains("operation: save remote state"));
        assert!(formatted.contains("path: state.snp"));
        assert!(formatted.contains("kind: permission_denied"));
    }

    #[test]
    fn structured_sync_error_parses_and_renders_for_users() {
        let rpc_error = RPCError::new(
            RPCErrorKind::Other,
            StructuredSyncError::remote(
                "apply details",
                Some(PathBuf::from("blocked/file.txt")),
                io::Error::from(io::ErrorKind::PermissionDenied),
            )
            .to_string(),
        );

        let rendered = render_rpc_error(&rpc_error);

        assert!(rendered.contains("remote apply details failed"));
        assert!(rendered.contains("blocked/file.txt"));
        assert!(rendered.contains("permission_denied"));
    }

    #[test]
    fn structured_sync_error_rendering_preserves_recovery_advice() {
        let error = StructuredSyncError {
            version: 1,
            side: "remote".to_string(),
            operation: "check apply recovery".to_string(),
            path: Some(PathBuf::from("profile.remotes/state")),
            kind: "other".to_string(),
            sources: Vec::new(),
            message: "previous Duet apply attempt did not finish\nRecovery: filesystem changes were applied, but Duet state may not have been saved on this side."
                .to_string(),
        };
        let rpc_error = RPCError::new(RPCErrorKind::Other, error.to_string());

        let rendered = render_rpc_error(&rpc_error);

        assert!(rendered.contains("remote check apply recovery failed"));
        assert!(rendered.contains("Recovery: filesystem changes were applied"));
        assert!(rendered.contains("state may not have been saved"));
    }

    #[test]
    fn setup_message_rendering_keeps_human_hint() {
        let rendered = render_message(
            "setup",
            "open SSH session",
            None,
            "Permission denied (publickey). Try chmod 600 ~/.ssh/id_ed25519.",
        );

        assert!(rendered.contains("setup open SSH session failed"));
        assert!(rendered.contains("permission_denied"));
        assert!(rendered.contains("Try chmod 600"));
        assert!(!rendered.contains("\"Permission denied"));
    }

    #[test]
    fn structured_sync_error_preserves_source_chain() {
        let report = Err::<(), _>(eyre!("inner permission denied"))
            .wrap_err("outer setup failed")
            .unwrap_err();
        let error = StructuredSyncError::from_report(
            "setup",
            "launch server",
            Some(PathBuf::from("remote.log")),
            report,
        );
        let formatted = error.to_string();
        let parsed = StructuredSyncError::parse(&formatted).unwrap();
        let rendered = parsed.render_for_user();

        assert_eq!(parsed.kind, "permission_denied");
        assert_eq!(parsed.sources, vec!["inner permission denied"]);
        assert!(formatted.contains("source: inner permission denied"));
        assert!(rendered.contains("caused by: inner permission denied"));
    }

    #[test]
    fn structured_sync_error_escapes_control_characters_for_users() {
        let rendered = render_message(
            "remote",
            "apply",
            Some(PathBuf::from("bad\x1b[31m\nname")),
            "failed\x1b[0m\nRecovery: inspect\x07 path",
        );

        assert!(!rendered.contains('\x1b'));
        assert!(!rendered.contains('\x07'));
        assert!(rendered.contains("\\u{1b}"));
        assert!(rendered.contains("\\u{7}"));
        assert!(rendered.contains("\\nname"));
    }

    #[test]
    fn parsed_sync_error_escapes_metadata_for_users() {
        let rpc_error = RPCError::new(
            RPCErrorKind::Other,
            "duet-sync-error-v1\nside: remote\x1b]2;pwned\x07\noperation: apply\x1b[31m\nkind: permission\x1b[0m\nmessage: failed",
        );

        let rendered = render_rpc_error(&rpc_error);

        assert!(!rendered.contains('\x1b'));
        assert!(!rendered.contains('\x07'));
        assert!(rendered.contains("\\u{1b}"));
        assert!(rendered.contains("\\u{7}"));
    }
}