cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use super::tree_ops::{
    BookmarkEntry, BookmarkFolder, BookmarkNode, BookmarkTree, parse_folder_path,
};
use super::{SafariBookmarkItem, SafariBookmarkItemKind};
use crate::MacosError;
use plist::{Dictionary, Value};
use std::path::Path;
use uuid::Uuid;

pub(super) fn load_tree_from_path(path: &Path) -> Result<BookmarkTree, MacosError> {
    let value = Value::from_file(path)
        .map_err(|err| MacosError::Other(format!("plist decode failed: {err}")))?;
    tree_from_value(&value)
}

#[cfg(test)]
pub(super) fn save_tree_to_path(tree: &BookmarkTree, path: &Path) -> Result<(), MacosError> {
    tree_to_value(tree)
        .to_file_xml(path)
        .map_err(|err| MacosError::Other(format!("plist write failed: {err}")))
}

pub(super) fn add_bookmark_to_path(
    path: &Path,
    folder: Option<&str>,
    title: &str,
    url: &str,
) -> Result<SafariBookmarkItem, MacosError> {
    let mut root = load_root_value(path)?;
    let children = resolve_children_mut_value(&mut root, folder)?;
    let duplicate = children
        .iter()
        .any(|value| bookmark_matches(value, title, url));
    if duplicate {
        return Err(MacosError::Other("duplicate bookmark".to_string()));
    }

    children.push(make_leaf_value(title, url));
    save_root_value(path, &root)?;

    Ok(SafariBookmarkItem {
        kind: SafariBookmarkItemKind::Bookmark,
        title: Some(title.to_string()),
        url: Some(url.to_string()),
        folder_path: folder.unwrap_or_default().to_string(),
    })
}

pub(super) fn delete_bookmark_from_path(
    path: &Path,
    folder: Option<&str>,
    title: &str,
    url: &str,
) -> Result<SafariBookmarkItem, MacosError> {
    let mut root = load_root_value(path)?;
    let children = resolve_children_mut_value(&mut root, folder)?;
    let matches: Vec<usize> = children
        .iter()
        .enumerate()
        .filter_map(|(index, value)| bookmark_matches(value, title, url).then_some(index))
        .collect();

    let result = match matches.len() {
        0 => Err(MacosError::Other("bookmark not found".to_string())),
        1 => {
            let value = children.remove(matches[0]);
            bookmark_item_from_value(&value, folder.unwrap_or_default())
        }
        _ => Err(MacosError::Other("bookmark data conflict".to_string())),
    }?;

    save_root_value(path, &root)?;
    Ok(result)
}

fn tree_from_value(value: &Value) -> Result<BookmarkTree, MacosError> {
    let dict = value.as_dictionary().ok_or_else(|| {
        MacosError::Other("plist decode failed: root is not a dictionary".to_string())
    })?;
    let children = dict
        .get("Children")
        .and_then(Value::as_array)
        .ok_or_else(|| MacosError::Other("plist decode failed: missing Children".to_string()))?;

    let mut parsed_children = Vec::new();
    for child in children {
        if let Some(node) = node_from_value(child)? {
            parsed_children.push(node);
        }
    }

    Ok(BookmarkTree {
        children: parsed_children,
    })
}

fn node_from_value(value: &Value) -> Result<Option<BookmarkNode>, MacosError> {
    let dict = value.as_dictionary().ok_or_else(|| {
        MacosError::Other("plist decode failed: bookmark node is not a dictionary".to_string())
    })?;
    let Some(bookmark_type) = dict.get("WebBookmarkType").and_then(Value::as_string) else {
        return Ok(None);
    };

    match bookmark_type {
        "WebBookmarkTypeList" => {
            let title = dict
                .get("Title")
                .and_then(Value::as_string)
                .unwrap_or_default()
                .to_string();
            let mut children = Vec::new();
            if let Some(values) = dict.get("Children").and_then(Value::as_array) {
                for child in values {
                    if let Some(node) = node_from_value(child)? {
                        children.push(node);
                    }
                }
            }
            Ok(Some(BookmarkNode::Folder(BookmarkFolder {
                title,
                children,
            })))
        }
        "WebBookmarkTypeLeaf" => {
            let title = dict
                .get("URIDictionary")
                .and_then(Value::as_dictionary)
                .and_then(|uri| uri.get("title"))
                .and_then(Value::as_string)
                .unwrap_or_default()
                .to_string();
            let Some(url) = dict.get("URLString").and_then(Value::as_string) else {
                return Ok(None);
            };
            Ok(Some(BookmarkNode::Bookmark(BookmarkEntry {
                title,
                url: url.to_string(),
            })))
        }
        _ => Ok(None),
    }
}

#[cfg(test)]
fn tree_to_value(tree: &BookmarkTree) -> Value {
    let mut dict = Dictionary::new();
    dict.insert("Title".to_string(), Value::String(String::new()));
    dict.insert(
        "WebBookmarkType".to_string(),
        Value::String("WebBookmarkTypeList".to_string()),
    );
    dict.insert(
        "WebBookmarkFileVersion".to_string(),
        Value::Integer(1_i64.into()),
    );
    dict.insert(
        "Children".to_string(),
        Value::Array(tree.children.iter().map(node_to_value).collect()),
    );
    Value::Dictionary(dict)
}

#[cfg(test)]
fn node_to_value(node: &BookmarkNode) -> Value {
    match node {
        BookmarkNode::Folder(folder) => {
            let mut dict = Dictionary::new();
            dict.insert("Title".to_string(), Value::String(folder.title.clone()));
            dict.insert(
                "WebBookmarkType".to_string(),
                Value::String("WebBookmarkTypeList".to_string()),
            );
            dict.insert(
                "Children".to_string(),
                Value::Array(folder.children.iter().map(node_to_value).collect()),
            );
            Value::Dictionary(dict)
        }
        BookmarkNode::Bookmark(entry) => {
            let mut uri = Dictionary::new();
            uri.insert("title".to_string(), Value::String(entry.title.clone()));

            let mut dict = Dictionary::new();
            dict.insert(
                "WebBookmarkType".to_string(),
                Value::String("WebBookmarkTypeLeaf".to_string()),
            );
            dict.insert("URIDictionary".to_string(), Value::Dictionary(uri));
            dict.insert("URLString".to_string(), Value::String(entry.url.clone()));
            Value::Dictionary(dict)
        }
    }
}

fn load_root_value(path: &Path) -> Result<Value, MacosError> {
    Value::from_file(path).map_err(|err| MacosError::Other(format!("plist decode failed: {err}")))
}

fn save_root_value(path: &Path, root: &Value) -> Result<(), MacosError> {
    let parent = path
        .parent()
        .ok_or_else(|| MacosError::Other("invalid path".to_string()))?;
    let mut temp = tempfile::NamedTempFile::new_in(parent)
        .map_err(|err| MacosError::Other(format!("failed to create temp file: {err}")))?;
    root.to_writer_binary(&mut temp)
        .map_err(|err| MacosError::Other(format!("plist write failed: {err}")))?;
    temp.persist(path)
        .map_err(|err| MacosError::Other(format!("failed to persist plist: {err}")))?;
    Ok(())
}

fn resolve_children_mut_value<'a>(
    root: &'a mut Value,
    folder: Option<&str>,
) -> Result<&'a mut Vec<Value>, MacosError> {
    let dict = root.as_dictionary_mut().ok_or_else(|| {
        MacosError::Other("plist decode failed: root is not a dictionary".to_string())
    })?;
    let mut children = ensure_children_array(dict)?;

    let Some(folder) = folder else {
        return Ok(children);
    };

    let parts = parse_folder_path(folder)?;
    for part in parts {
        let Some(index) = children
            .iter()
            .position(|value| folder_matches(value, &part))
        else {
            return Err(MacosError::Other("invalid folder path".to_string()));
        };
        let next = children
            .get_mut(index)
            .and_then(Value::as_dictionary_mut)
            .map(ensure_children_array)
            .transpose()?
            .ok_or_else(|| MacosError::Other("invalid folder path".to_string()))?;
        children = next;
    }

    Ok(children)
}

fn ensure_children_array(dict: &mut Dictionary) -> Result<&mut Vec<Value>, MacosError> {
    if !dict.contains_key("Children") {
        dict.insert("Children".to_string(), Value::Array(Vec::new()));
    }

    dict.get_mut("Children")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| MacosError::Other("invalid folder path".to_string()))
}

fn folder_matches(value: &Value, title: &str) -> bool {
    value
        .as_dictionary()
        .map(|dict| {
            dict.get("WebBookmarkType").and_then(Value::as_string) == Some("WebBookmarkTypeList")
                && dict.get("Title").and_then(Value::as_string) == Some(title)
        })
        .unwrap_or(false)
}

fn bookmark_matches(value: &Value, title: &str, url: &str) -> bool {
    value
        .as_dictionary()
        .map(|dict| {
            dict.get("WebBookmarkType").and_then(Value::as_string) == Some("WebBookmarkTypeLeaf")
                && dict
                    .get("URIDictionary")
                    .and_then(Value::as_dictionary)
                    .and_then(|uri| uri.get("title"))
                    .and_then(Value::as_string)
                    == Some(title)
                && dict.get("URLString").and_then(Value::as_string) == Some(url)
        })
        .unwrap_or(false)
}

fn make_leaf_value(title: &str, url: &str) -> Value {
    let mut uri = Dictionary::new();
    uri.insert("title".to_string(), Value::String(title.to_string()));

    let mut dict = Dictionary::new();
    dict.insert(
        "WebBookmarkType".to_string(),
        Value::String("WebBookmarkTypeLeaf".to_string()),
    );
    dict.insert("URIDictionary".to_string(), Value::Dictionary(uri));
    dict.insert("URLString".to_string(), Value::String(url.to_string()));
    dict.insert(
        "WebBookmarkUUID".to_string(),
        Value::String(Uuid::new_v4().to_string().to_uppercase()),
    );
    Value::Dictionary(dict)
}

fn bookmark_item_from_value(
    value: &Value,
    folder_path: &str,
) -> Result<SafariBookmarkItem, MacosError> {
    let dict = value
        .as_dictionary()
        .ok_or_else(|| MacosError::Other("bookmark not found".to_string()))?;
    let title = dict
        .get("URIDictionary")
        .and_then(Value::as_dictionary)
        .and_then(|uri| uri.get("title"))
        .and_then(Value::as_string)
        .map(ToOwned::to_owned);
    let url = dict
        .get("URLString")
        .and_then(Value::as_string)
        .map(ToOwned::to_owned);

    Ok(SafariBookmarkItem {
        kind: SafariBookmarkItemKind::Bookmark,
        title,
        url,
        folder_path: folder_path.to_string(),
    })
}

#[cfg(test)]
mod tests {
    use super::{add_bookmark_to_path, delete_bookmark_from_path};
    use plist::{Dictionary, Value};
    use tempfile::tempdir;

    fn empty_folder_root() -> Value {
        let mut folder = Dictionary::new();
        folder.insert("Title".to_string(), Value::String("Work".to_string()));
        folder.insert(
            "WebBookmarkType".to_string(),
            Value::String("WebBookmarkTypeList".to_string()),
        );
        folder.insert(
            "WebBookmarkUUID".to_string(),
            Value::String("F0776FB0-47B8-4DE0-BF4A-9D8ADFA9332A".to_string()),
        );

        let mut root = Dictionary::new();
        root.insert("Title".to_string(), Value::String(String::new()));
        root.insert(
            "WebBookmarkType".to_string(),
            Value::String("WebBookmarkTypeList".to_string()),
        );
        root.insert(
            "WebBookmarkFileVersion".to_string(),
            Value::Integer(1_i64.into()),
        );
        root.insert(
            "Children".to_string(),
            Value::Array(vec![Value::Dictionary(folder)]),
        );
        Value::Dictionary(root)
    }

    #[test]
    fn add_bookmark_to_empty_folder_without_children_key_succeeds() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("Bookmarks.plist");
        empty_folder_root()
            .to_file_binary(&path)
            .expect("write plist root");

        let result = add_bookmark_to_path(
            &path,
            Some("Work"),
            "Smoke Test",
            "https://example.com/smoke",
        )
        .expect("add bookmark");

        assert_eq!(result.title.as_deref(), Some("Smoke Test"));
        assert_eq!(result.url.as_deref(), Some("https://example.com/smoke"));
        assert_eq!(result.folder_path, "Work");
    }

    #[test]
    fn delete_from_empty_folder_without_children_key_returns_not_found() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("Bookmarks.plist");
        empty_folder_root()
            .to_file_binary(&path)
            .expect("write plist root");

        let err = delete_bookmark_from_path(&path, Some("Work"), "Missing", "https://example.com")
            .expect_err("delete should fail");

        assert!(err.to_string().contains("bookmark not found"));
    }
}