iwcore 0.1.23

IntelliWallet Core - Password manager library with AES-256 encryption
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use iwcore::backup::{check_db_version, get_db_version};
use iwcore::{IWField, IWItem, Wallet};
use tempfile::TempDir;

const TEST_PASSWORD: &str = "KuiperBelt30au";

fn testdata_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
}

fn setup_wallet(db_filename: &str) -> (Wallet, TempDir) {
    let source = testdata_dir().join(db_filename);
    assert!(source.exists(), "Test database not found: {:?}", source);

    let temp_dir = TempDir::new().unwrap();
    let dest = temp_dir.path().join("nswallet.dat");
    fs::copy(&source, &dest).expect("Failed to copy test database");

    let wallet = Wallet::open(temp_dir.path()).expect("Failed to open wallet");
    (wallet, temp_dir)
}

#[test]
fn dump_database_contents() {
    // --- nswallet.dat: full dump ---
    dump_main_database();

    // --- nswallet_old.dat: version only ---
    dump_version_only("nswallet_old.dat");

    // --- nswallet_from_future.dat: version only ---
    dump_version_only("nswallet_from_future.dat");

    // Clean up generated .md files
    let td = testdata_dir();
    for name in ["nswallet.dat.md", "nswallet_old.dat.md", "nswallet_from_future.dat.md"] {
        let path = td.join(name);
        if path.exists() {
            fs::remove_file(&path).ok();
        }
    }
}

fn dump_main_database() {
    let (mut wallet, _temp_dir) = setup_wallet("nswallet.dat");
    wallet.unlock(TEST_PASSWORD).unwrap();

    let properties = wallet.get_properties().unwrap();
    let labels = wallet.get_labels().unwrap();

    // Clone items/fields so we don't hold borrows on wallet
    let items: Vec<_> = wallet.get_items().unwrap().to_vec();
    let fields: Vec<_> = wallet.get_fields().unwrap().to_vec();
    let deleted_items = wallet.get_deleted_items().unwrap();
    let deleted_fields = wallet.get_deleted_fields().unwrap();

    wallet.close();

    // Build item lookup for path computation
    let item_map: HashMap<&str, &IWItem> =
        items.iter().map(|i| (i.item_id.as_str(), i)).collect();

    // Also include deleted items in the map for path resolution
    let deleted_map: HashMap<&str, &IWItem> =
        deleted_items.iter().map(|i| (i.item_id.as_str(), i)).collect();

    let compute_path = |item: &IWItem| -> String {
        let mut parts: Vec<String> = Vec::new();
        let mut current_parent = item.parent_id.as_deref();
        loop {
            match current_parent {
                None => break,
                Some("__ROOT__") => break,
                Some(pid) => {
                    if let Some(parent) = item_map.get(pid).or_else(|| deleted_map.get(pid)) {
                        if parent.is_root() {
                            break;
                        }
                        parts.push(parent.name.clone());
                        current_parent = parent.parent_id.as_deref();
                    } else {
                        parts.push(format!("?{}", pid));
                        break;
                    }
                }
            }
        }
        parts.reverse();
        if parts.is_empty() {
            "/".to_string()
        } else {
            format!("/{}", parts.join("/"))
        }
    };

    // Group fields by item_id
    let mut fields_by_item: HashMap<&str, Vec<&IWField>> =
        HashMap::new();
    for f in &fields {
        fields_by_item.entry(f.item_id.as_str()).or_default().push(f);
    }
    // Sort fields within each item by sort_weight
    for v in fields_by_item.values_mut() {
        v.sort_by_key(|f| f.sort_weight);
    }

    let mut deleted_fields_by_item: HashMap<&str, Vec<&IWField>> =
        HashMap::new();
    for f in &deleted_fields {
        deleted_fields_by_item
            .entry(f.item_id.as_str())
            .or_default()
            .push(f);
    }
    for v in deleted_fields_by_item.values_mut() {
        v.sort_by_key(|f| f.sort_weight);
    }

    // Sort active items alphabetically (exclude ROOT)
    let mut active_items: Vec<_> = items.iter().filter(|i| !i.is_root()).collect();
    active_items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

    // Sort deleted items alphabetically
    let mut sorted_deleted: Vec<_> = deleted_items.iter().collect();
    sorted_deleted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

    // Build markdown
    let mut md = String::new();
    md.push_str("# nswallet.dat\n\n");
    md.push_str("> Auto-generated by `cargo test dump_database_contents`\n\n");

    // Properties
    md.push_str("## Properties\n\n");
    md.push_str("| Property | Value |\n");
    md.push_str("|----------|-------|\n");
    md.push_str(&format!("| Version | {} |\n", properties.version));
    md.push_str(&format!("| Language | {} |\n", properties.lang));
    md.push_str(&format!(
        "| Encryption Count | {} |\n",
        properties.encryption_count
    ));
    md.push_str(&format!("| Database ID | {} |\n", properties.database_id));
    md.push_str(&format!(
        "| Sync Timestamp | {} |\n",
        properties
            .sync_timestamp
            .map_or("–".to_string(), |t| t.to_string())
    ));
    md.push_str(&format!(
        "| Update Timestamp | {} |\n",
        properties
            .update_timestamp
            .map_or("–".to_string(), |t| t.to_string())
    ));
    md.push('\n');

    // Labels
    md.push_str("## Labels\n\n");
    md.push_str("| Type | Name | Value Type | Icon | System |\n");
    md.push_str("|------|------|------------|------|--------|\n");
    for label in &labels {
        md.push_str(&format!(
            "| {} | {} | {} | {} | {} |\n",
            label.field_type,
            label.name,
            label.value_type,
            label.icon,
            if label.system { "yes" } else { "no" }
        ));
    }
    md.push('\n');

    // Active Records
    md.push_str("## Active Records\n\n");
    for item in &active_items {
        let path = compute_path(item);
        md.push_str(&format!(
            "### {} (id: {}, icon: {}, folder: {})\n\n",
            item.name,
            item.item_id,
            item.icon,
            if item.folder { "yes" } else { "no" }
        ));
        md.push_str(&format!("Path: `{}`\n\n", path));

        if let Some(item_fields) = fields_by_item.get(item.item_id.as_str()) {
            md.push_str("| Label | Type | Value |\n");
            md.push_str("|-------|------|-------|\n");
            for f in item_fields {
                // Escape pipe characters in values
                let escaped_value = f.value.replace('|', "\\|");
                md.push_str(&format!(
                    "| {} | {} | {} |\n",
                    f.label, f.field_type, escaped_value
                ));
            }
        } else {
            md.push_str("*(no fields)*\n");
        }
        md.push('\n');
    }

    // Deleted Records
    md.push_str("## Deleted Records\n\n");
    if sorted_deleted.is_empty() {
        md.push_str("*(none)*\n\n");
    } else {
        for item in &sorted_deleted {
            let path = compute_path(item);
            md.push_str(&format!(
                "### {} (id: {}, icon: {}, folder: {})\n\n",
                item.name,
                item.item_id,
                item.icon,
                if item.folder { "yes" } else { "no" }
            ));
            md.push_str(&format!("Path: `{}`\n\n", path));

            if let Some(item_fields) = deleted_fields_by_item.get(item.item_id.as_str()) {
                md.push_str("| Label | Type | Value |\n");
                md.push_str("|-------|------|-------|\n");
                for f in item_fields {
                    let escaped_value = f.value.replace('|', "\\|");
                    md.push_str(&format!(
                        "| {} | {} | {} |\n",
                        f.label, f.field_type, escaped_value
                    ));
                }
            } else {
                md.push_str("*(no fields)*\n");
            }
            md.push('\n');
        }
    }

    // Statistics
    let total_items = items.len() - 1; // exclude ROOT
    let folder_count = items.iter().filter(|i| i.folder && !i.is_root()).count();
    let document_count = items.iter().filter(|i| !i.folder && !i.is_root()).count();
    let total_fields = fields.len();
    let total_deleted_items = deleted_items.len();
    let total_deleted_fields = deleted_fields.len();
    let total_labels = labels.len();
    let system_labels = labels.iter().filter(|l| l.system).count();
    let custom_labels = labels.iter().filter(|l| !l.system).count();

    md.push_str("## Statistics\n\n");
    md.push_str("| Metric | Count |\n");
    md.push_str("|--------|-------|\n");
    md.push_str(&format!("| Total active items | {} |\n", total_items));
    md.push_str(&format!("| Folders | {} |\n", folder_count));
    md.push_str(&format!("| Documents | {} |\n", document_count));
    md.push_str(&format!("| Total active fields | {} |\n", total_fields));
    md.push_str(&format!("| Deleted items | {} |\n", total_deleted_items));
    md.push_str(&format!("| Deleted fields | {} |\n", total_deleted_fields));
    md.push_str(&format!("| Total labels | {} |\n", total_labels));
    md.push_str(&format!("| System labels | {} |\n", system_labels));
    md.push_str(&format!("| Custom labels | {} |\n", custom_labels));

    let output_path = testdata_dir().join("nswallet.dat.md");
    fs::write(&output_path, &md).expect("Failed to write nswallet.dat.md");
    println!("Wrote {}", output_path.display());
}

fn dump_version_only(db_filename: &str) {
    let db_path = testdata_dir().join(db_filename);
    assert!(db_path.exists(), "Test database not found: {:?}", db_path);

    let version = get_db_version(&db_path).unwrap();
    let compatible = check_db_version(&db_path).unwrap();

    let mut md = String::new();
    md.push_str(&format!("# {}\n\n", db_filename));
    md.push_str("> Auto-generated by `cargo test dump_database_contents`\n\n");
    md.push_str("| Property | Value |\n");
    md.push_str("|----------|-------|\n");
    md.push_str(&format!("| Version | {} |\n", version));
    md.push_str(&format!(
        "| Compatible | {} |\n",
        if compatible { "yes" } else { "no" }
    ));

    let output_path = testdata_dir().join(format!("{}.md", db_filename));
    fs::write(&output_path, &md).expect(&format!("Failed to write {}.md", db_filename));
    println!("Wrote {}", output_path.display());
}