kushi 0.1.0

Build-time boilerplate generator for cxx-qt bridges. Declare qproperties and list-model roles once in build.rs for the full bridge generated. Not affiliated with the cxx-qt project or KDAB.
Documentation
use kushi::{Kind, ListModelBridge, ObjectBridge};
use std::fs;
use std::path::Path;

fn check_golden(name: &str, generated: &str) {
    let path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/golden")
        .join(name);
    if std::env::var("KUSHI_BLESS").is_ok() {
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, generated).unwrap();
        return;
    }
    let expected = fs::read_to_string(&path).unwrap_or_else(|_| {
        panic!("missing golden {name}; run KUSHI_BLESS=1 cargo test to create it")
    });
    assert_eq!(
        generated, expected,
        "golden mismatch for {name}; run KUSHI_BLESS=1 cargo test to update"
    );
}

fn settings_fixture() -> ObjectBridge {
    ObjectBridge::new("DemoSettings")
        .toml_path(r#"std::path::PathBuf::from("settings.toml")"#)
        .prop("show_hidden", Kind::Bool, "false")
        .prop("card_zoom", Kind::F64, "1.0")
        .prop("nav_width", Kind::I32, "184")
        .prop("greeting", Kind::QString, "hii")
}

fn model_fixture() -> ListModelBridge {
    ListModelBridge::new("MiniModel")
        .item_type("super::Entry")
        .items_init("Vec::new()")
        .role("name", Kind::QString, "name")
        .role("hidden", Kind::Bool, "hidden")
        .role("plays", Kind::I32, "plays")
        .role_fn("display", "super::role_display")
        .snapshot_map("getItem")
        .custom_invokable(
            "toggleHidden",
            "fn toggle_hidden(self: Pin<&mut MiniModel>, row: i32);",
        )
}

#[test]
fn settings_bridge_golden() {
    check_golden("settings_bridge.rs", &settings_fixture().generate());
}

#[test]
fn list_model_bridge_golden() {
    check_golden("list_model_bridge.rs", &model_fixture().generate());
}

#[test]
fn model_without_snapshot_map_omits_qmap() {
    let out = ListModelBridge::new("Plain")
        .item_type("super::Entry")
        .role("name", Kind::QString, "name")
        .generate();
    assert!(!out.contains("QMap_QString_QVariant"));
    assert!(!out.contains("fn get_item"));
}

#[test]
fn qstring_default_is_escaped() {
    let out = ObjectBridge::new("Esc")
        .prop("greeting", Kind::QString, r#"say "hi" \ bye"#)
        .generate();
    assert!(out.contains(r#""say \"hi\" \\ bye".to_string()"#));
}

#[test]
fn single_word_prop_gets_no_cxx_name() {
    let out = ObjectBridge::new("Single")
        .prop("greeting", Kind::QString, "x")
        .generate();
    assert!(out.contains("#[qproperty(QString, greeting)]"));
}

#[test]
fn snake_case_names_produce_camel_cxx_names() {
    let out = settings_fixture().generate();
    assert!(out.contains(r#"#[qproperty(bool, show_hidden, cxx_name = "showHidden")]"#));
    assert!(out.contains(r#"#[cxx_name = "applyShowHidden"]"#));
}

#[test]
#[should_panic(expected = "must be snake_case")]
fn camel_case_prop_name_rejected() {
    let _ = ObjectBridge::new("Bad").prop("showHidden", Kind::Bool, "false");
}

#[test]
#[should_panic(expected = "must be snake_case")]
fn camel_case_role_name_rejected() {
    let _ = ListModelBridge::new("Bad").role("showHidden", Kind::Bool, "hidden");
}

#[test]
fn write_into_is_stable_and_idempotent() {
    let dir = std::env::temp_dir().join(format!("kushi-test-{}", std::process::id()));
    fs::create_dir_all(&dir).unwrap();
    let bridge = settings_fixture();
    let first = bridge.write_into(&dir);
    let second = bridge.write_into(&dir);
    assert_eq!(first, second);
    assert_eq!(first.file_name().unwrap(), "demo_settings.rs");
    assert_eq!(fs::read_to_string(&first).unwrap(), bridge.generate());
    fs::remove_dir_all(&dir).unwrap();
}

fn external_fixture() -> ObjectBridge {
    ObjectBridge::new("AppObjectBridge")
        .external_data("super::AppSettings", "super::AppSettings::load()")
        .threading()
        .prop_at("show_hidden", Kind::Bool, "display.show_hidden")
        .prop_at("card_flow", Kind::QString, "display.card_flow")
        .prop_custom_apply("ui_scale", Kind::F64, "display.scale")
        .prop_readonly("fill_fields", Kind::Bool, "theme.fill_fields")
        .qsignal("theme_changed")
        .raw_field("watcher", "Option<super::FileWatcher>", "None")
        .json_accessor(
            "categories",
            "Vec<super::CategoryEntry>",
            "categories",
            "categories_changed",
        )
        .reload_hook("reload_extras")
}

#[test]
fn external_settings_golden() {
    check_golden(
        "external_settings_bridge.rs",
        &external_fixture().generate(),
    );
}

#[test]
fn external_custom_apply_declares_but_does_not_implement() {
    let out = external_fixture().generate();
    assert!(out.contains(r#"#[cxx_name = "applyUiScale"]"#));
    assert!(!out.contains("fn apply_ui_scale(mut self"));
}

#[test]
fn external_readonly_prop_has_no_apply() {
    let out = external_fixture().generate();
    assert!(out.contains("#[qproperty(bool, fill_fields"));
    assert!(!out.contains("applyFillFields"));
}

#[test]
fn external_persist_is_a_user_hook() {
    let out = external_fixture().generate();
    assert!(!out.contains("fn persist"));
    assert!(out.contains("self.persist();"));
}

#[test]
fn external_snapshot_starts_from_loaded_data() {
    let out = external_fixture().generate();
    assert!(out.contains("let mut s = super::AppSettings::load();"));
    assert!(out.contains("s.display.show_hidden = self.show_hidden;"));
    assert!(out.contains("s.display.card_flow = self.card_flow.to_string();"));
}

#[test]
fn external_unpersisted_raw_field_survives_reload() {
    let out = external_fixture().generate();
    assert!(out.contains("watcher: None,"));
    assert!(!out.contains("get_mut().watcher"));
    assert!(out.contains("get_mut().categories = s.categories.clone();"));
}

#[test]
fn external_qsignals_and_threading_are_declared() {
    let out = external_fixture().generate();
    assert!(out.contains(r#"#[cxx_name = "categoriesChanged"]"#));
    assert!(out.contains("fn theme_changed(self: Pin<&mut AppObjectBridge>);"));
    assert!(out.contains("impl cxx_qt::Threading for AppObjectBridge {}"));
}

#[test]
fn external_reload_calls_hook_last() {
    let out = external_fixture().generate();
    assert!(out.contains("self.as_mut().reload_extras(&s);"));
}

#[test]
fn json_accessor_generates_decls_body_and_signal() {
    let out = external_fixture().generate();
    assert!(out.contains(r#"#[cxx_name = "categoriesJson"]"#));
    assert!(out.contains(r#"#[cxx_name = "applyCategoriesJson"]"#));
    assert!(out.contains("serde_json::from_str::<Vec<super::CategoryEntry>>"));
    assert!(out.contains(r#"#[cxx_name = "categoriesChanged"]"#));
    assert!(out.contains("self.as_mut().categories_changed();"));
    assert!(out.contains(r#"tracing::error!("bad categories json: {}", e)"#));
}

#[test]
#[should_panic(expected = "remove the duplicate declaration")]
fn json_accessor_rejects_duplicate_signal() {
    let _ = ObjectBridge::new("Bad")
        .external_data("super::S", "super::S::load()")
        .qsignal("categories_changed")
        .json_accessor(
            "categories",
            "Vec<String>",
            "categories",
            "categories_changed",
        );
}

fn download_like_fixture() -> ListModelBridge {
    ListModelBridge::new("QueueModel")
        .item_type("super::Job")
        .items_name("entries")
        .custom_default()
        .qproperty("count", Kind::I32)
        .qproperty("running_count", Kind::I32)
        .qproperty("hero_id", Kind::QString)
        .role("id", Kind::QString, "id")
        .role("progress", Kind::F64, "progress")
        .role_fn("status", "super::status_label")
        .qsignal_raw("fn job_failed(self: Pin<&mut QueueModel>, id: &QString, error: &QString);")
        .custom_invokable_raw("fn pause(self: Pin<&mut QueueModel>, id: &QString);")
        .custom_invokable("speedJson", "fn speed_json(self: &QueueModel) -> QString;")
        .row_ops()
}

#[test]
fn download_like_model_golden() {
    check_golden("queue_model_bridge.rs", &download_like_fixture().generate());
}

#[test]
fn model_qproperties_and_custom_default() {
    let out = download_like_fixture().generate();
    assert!(out.contains(r#"#[qproperty(i32, running_count, cxx_name = "runningCount")]"#));
    assert!(out.contains("pub running_count: i32,"));
    assert!(out.contains("pub entries: Vec<super::Job>,"));
    assert!(!out.contains("impl Default for QueueModelRust"));
    assert!(out.contains("self.entries.len() as i32"));
}

#[test]
fn model_raw_signal_and_invokable_have_no_cxx_name() {
    let out = download_like_fixture().generate();
    let sig_pos = out.find("fn job_failed").unwrap();
    let preceding = &out[sig_pos.saturating_sub(60)..sig_pos];
    assert!(preceding.contains("#[qsignal]") && !preceding.contains("cxx_name"));
    let inv_pos = out.find("fn pause").unwrap();
    let preceding = &out[inv_pos.saturating_sub(60)..inv_pos];
    assert!(preceding.contains("#[qinvokable]") && !preceding.contains("cxx_name"));
}

#[test]
fn model_row_ops_are_opt_in() {
    let with = download_like_fixture().generate();
    assert!(with.contains("beginInsertRows") && with.contains("endRemoveRows"));
    let without = model_fixture().generate();
    assert!(!without.contains("beginInsertRows"));
}

#[test]
#[should_panic(expected = "require external_data")]
fn external_props_without_external_data_rejected() {
    let _ = ObjectBridge::new("Bad")
        .prop_at("show_hidden", Kind::Bool, "display.show_hidden")
        .generate();
}

#[test]
fn stage_files_copies_into_dir() {
    let dir = std::env::temp_dir().join(format!("kushi-stage-{}", std::process::id()));
    let src_dir = dir.join("src");
    let out = dir.join("out");
    fs::create_dir_all(&src_dir).unwrap();
    fs::create_dir_all(&out).unwrap();
    fs::write(src_dir.join("a.rs"), "pub fn a() {}").unwrap();
    let staged = kushi::stage_files([src_dir.join("a.rs")], &out);
    assert_eq!(staged, vec![out.join("a.rs")]);
    assert_eq!(fs::read_to_string(&staged[0]).unwrap(), "pub fn a() {}");
    let again = kushi::stage_files([src_dir.join("a.rs")], &out);
    assert_eq!(staged, again);
    fs::remove_dir_all(&dir).unwrap();
}

#[test]
#[should_panic(expected = "unique names")]
fn stage_files_rejects_duplicate_names() {
    let dir = std::env::temp_dir().join(format!("kushi-stage-dup-{}", std::process::id()));
    let x = dir.join("x");
    fs::create_dir_all(&x).unwrap();
    fs::write(x.join("a.rs"), "").unwrap();
    let _ = kushi::stage_files([x.join("a.rs"), dir.join("y").join("a.rs")], &dir);
}

#[test]
fn role_names_are_camel_cased_for_qml() {
    let out = download_like_fixture()
        .role("bytes_total", Kind::F64, "bytes_total")
        .generate();
    assert!(out.contains(r#"QByteArray::from("bytesTotal")"#));
    assert!(!out.contains(r#"QByteArray::from("bytes_total")"#));
}