padzapp 1.7.0

An ergonomic, context-aware scratch pad library with plain text storage
Documentation
use padzapp::api::PadzApi;
use padzapp::commands::{NestingMode, PadzPaths};
use padzapp::model::Scope;
use padzapp::store::bucketed::BucketedStore;
use padzapp::store::mem_backend::MemBackend;

/// Strip ANSI styling so substring assertions are stable across TTY/non-TTY
/// test environments — the ambiguity error highlights the matched substring
/// inside each title, so a literal `contains("Groceries")` check would fail
/// when colors are enabled.
fn strip_ansi(s: &str) -> String {
    console::strip_ansi_codes(s).to_string()
}

fn setup() -> PadzApi<BucketedStore<MemBackend>> {
    let store = BucketedStore::new(
        MemBackend::new(),
        MemBackend::new(),
        MemBackend::new(),
        MemBackend::new(),
    );
    let paths = PadzPaths {
        project: Some(std::path::PathBuf::from(".padz")),
        global: std::path::PathBuf::from(".padz"),
    };
    let mut api = PadzApi::new(store, paths);

    // Create some pads
    api.create_pad(
        Scope::Project,
        "Groceries".to_string(),
        "Milk, Eggs".to_string(),
        None,
    )
    .unwrap();
    api.create_pad(
        Scope::Project,
        "Grocery List".to_string(),
        "Bread, Butter".to_string(),
        None,
    )
    .unwrap();
    api.create_pad(Scope::Project, "Gold".to_string(), "Au".to_string(), None)
        .unwrap();

    api
}

#[test]
fn test_referencing_by_index() {
    let api = setup();
    // 1 -> Gold (newest/shortest title logic? Default is newest first)
    // Created Order: Groceries, Grocery List, Gold.
    // Indexing: Gold (1), Grocery List (2), Groceries (3).

    let res = api
        .view_pads(Scope::Project, &["1"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Gold");

    let res = api
        .view_pads(Scope::Project, &["3"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Groceries");
}

#[test]
fn test_referencing_multiple_indexes() {
    let api = setup();
    let res = api
        .view_pads(Scope::Project, &["1", "2"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 2);
    // Gold and Grocery List
}

#[test]
fn test_referencing_by_title_exact() {
    let api = setup();
    let res = api
        .view_pads(Scope::Project, &["Gold"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Gold");
}

#[test]
fn test_referencing_by_title_partial() {
    let api = setup();
    // "Gold" is matched by "old"
    let res = api
        .view_pads(Scope::Project, &["old"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Gold");
}

#[test]
fn test_referencing_by_title_multi_word_arg() {
    let api = setup();
    // "Grocery List" matched by "Grocery List" (passed as separate args by shell simulation)
    // Actually view_pads takes &[String]. The CLI passes ["Grocery", "List"].
    let res = api
        .view_pads(Scope::Project, &["Grocery", "List"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Grocery List");
}

#[test]
fn test_referencing_ambiguous() {
    let api = setup();
    // "Gro" matches "Groceries" and "Grocery List"
    let res = api.view_pads(Scope::Project, &["Gro"], NestingMode::Flat);
    assert!(res.is_err());
    let err = strip_ansi(&res.err().unwrap().to_string());
    assert!(err.contains("matches multiple pads"));
    // The error should list the matching pads so the user can pick one.
    assert!(err.contains("Groceries"));
    assert!(err.contains("Grocery List"));
}

#[test]
fn test_referencing_mixed_treated_as_title() {
    let api = setup();
    // "1" and "Gold". "1" is index, "Gold" is title.
    // Should be treated as title search "1 Gold".
    // "Gold" pad content is "Au". Title "Gold".
    // Search "1 Gold" -> No match.

    let res = api.view_pads(Scope::Project, &["1", "Gold"], NestingMode::Flat);
    assert!(res.is_err());
    let err = res.err().unwrap().to_string();
    assert!(err.contains("No pad found matching \"1 Gold\""));
}

#[test]
fn test_referencing_mixed_no_match() {
    let api = setup();
    let res = api.view_pads(Scope::Project, &["1", "Grocery"], NestingMode::Flat);
    assert!(res.is_err());
}

#[test]
fn test_referencing_does_not_match_deleted_titles() {
    let store = BucketedStore::new(
        MemBackend::new(),
        MemBackend::new(),
        MemBackend::new(),
        MemBackend::new(),
    );
    let paths = PadzPaths {
        project: Some(std::path::PathBuf::from(".padz")),
        global: std::path::PathBuf::from(".padz"),
    };
    let mut api = PadzApi::new(store, paths);

    // One active pad, then create a second pad with the same term and delete it.
    // Before the fix, a title search would match both (ambiguous). After the fix,
    // only the active one matches and `view_pads` resolves uniquely.
    api.create_pad(Scope::Project, "Shared Term Active".into(), "".into(), None)
        .unwrap();
    api.create_pad(
        Scope::Project,
        "Shared Term Trashed".into(),
        "".into(),
        None,
    )
    .unwrap();
    // "Shared Term Trashed" was created second, so it's index 1.
    api.delete_pads(Scope::Project, &["1"]).unwrap();

    let res = api
        .view_pads(Scope::Project, &["Shared"], NestingMode::Flat)
        .unwrap();
    assert_eq!(res.listed_pads.len(), 1);
    assert_eq!(res.listed_pads[0].pad.metadata.title, "Shared Term Active");
}