generated from nhcarrigan/template
b3d79a82ef
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
374 lines
12 KiB
Rust
374 lines
12 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::AppHandle;
|
|
use tauri_plugin_store::StoreExt;
|
|
|
|
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QuickAction {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub prompt: String,
|
|
pub icon: String,
|
|
pub is_default: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
fn get_default_quick_actions() -> Vec<QuickAction> {
|
|
let now = Utc::now();
|
|
vec![
|
|
QuickAction {
|
|
id: "default-review-pr".to_string(),
|
|
name: "Review PR".to_string(),
|
|
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
|
icon: "git-pull-request".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
QuickAction {
|
|
id: "default-run-tests".to_string(),
|
|
name: "Run Tests".to_string(),
|
|
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
|
|
icon: "play".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
QuickAction {
|
|
id: "default-explain-file".to_string(),
|
|
name: "Explain File".to_string(),
|
|
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
|
|
icon: "file-text".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
QuickAction {
|
|
id: "default-fix-error".to_string(),
|
|
name: "Fix Error".to_string(),
|
|
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
|
|
icon: "alert-circle".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
QuickAction {
|
|
id: "default-write-tests".to_string(),
|
|
name: "Write Tests".to_string(),
|
|
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
|
|
icon: "check-square".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
QuickAction {
|
|
id: "default-refactor".to_string(),
|
|
name: "Refactor".to_string(),
|
|
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
|
|
icon: "refresh-cw".to_string(),
|
|
is_default: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
]
|
|
}
|
|
|
|
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
|
|
let store = app
|
|
.store("hikari-quick-actions.json")
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
match store.get(QUICK_ACTIONS_STORE_KEY) {
|
|
Some(value) => {
|
|
let mut actions: Vec<QuickAction> =
|
|
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
|
|
|
let defaults = get_default_quick_actions();
|
|
for default in defaults {
|
|
if !actions.iter().any(|a| a.id == default.id) {
|
|
actions.push(default);
|
|
}
|
|
}
|
|
|
|
Ok(actions)
|
|
}
|
|
None => Ok(get_default_quick_actions()),
|
|
}
|
|
}
|
|
|
|
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
|
|
let store = app
|
|
.store("hikari-quick-actions.json")
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
|
|
store.set(QUICK_ACTIONS_STORE_KEY, value);
|
|
store.save().map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
|
|
let mut actions = load_all_quick_actions(&app)?;
|
|
|
|
actions.sort_by(|a, b| {
|
|
let default_cmp = b.is_default.cmp(&a.is_default);
|
|
if default_cmp == std::cmp::Ordering::Equal {
|
|
a.name.cmp(&b.name)
|
|
} else {
|
|
default_cmp
|
|
}
|
|
});
|
|
|
|
Ok(actions)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
|
|
let mut actions = load_all_quick_actions(&app)?;
|
|
|
|
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
|
|
let mut updated = action;
|
|
updated.is_default = existing.is_default;
|
|
*existing = updated;
|
|
} else {
|
|
actions.push(action);
|
|
}
|
|
|
|
save_all_quick_actions(&app, &actions)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
|
|
let mut actions = load_all_quick_actions(&app)?;
|
|
|
|
if actions
|
|
.iter()
|
|
.any(|a| a.id == action_id && a.is_default)
|
|
{
|
|
return Err("Cannot delete default quick actions".to_string());
|
|
}
|
|
|
|
actions.retain(|a| a.id != action_id);
|
|
save_all_quick_actions(&app, &actions)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
|
let mut actions = load_all_quick_actions(&app)?;
|
|
|
|
actions.retain(|a| !a.is_default);
|
|
actions.extend(get_default_quick_actions());
|
|
|
|
save_all_quick_actions(&app, &actions)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn create_test_action(id: &str, name: &str, is_default: bool) -> QuickAction {
|
|
QuickAction {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
prompt: "Test prompt".to_string(),
|
|
icon: "star".to_string(),
|
|
is_default,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_quick_actions_exist() {
|
|
let defaults = get_default_quick_actions();
|
|
assert!(!defaults.is_empty());
|
|
assert!(defaults.iter().all(|a| a.is_default));
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_quick_actions_have_required_fields() {
|
|
let defaults = get_default_quick_actions();
|
|
for action in defaults {
|
|
assert!(!action.id.is_empty());
|
|
assert!(!action.name.is_empty());
|
|
assert!(!action.prompt.is_empty());
|
|
assert!(!action.icon.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_quick_actions_count() {
|
|
let defaults = get_default_quick_actions();
|
|
// Should have 6 default actions
|
|
assert_eq!(defaults.len(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_quick_actions_have_unique_ids() {
|
|
let defaults = get_default_quick_actions();
|
|
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
|
|
ids.sort();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), defaults.len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_quick_actions_ids_start_with_default() {
|
|
let defaults = get_default_quick_actions();
|
|
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_quick_action_serialization() {
|
|
let action = create_test_action("test-1", "Test Action", false);
|
|
let json = serde_json::to_string(&action).expect("Failed to serialize");
|
|
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.id, action.id);
|
|
assert_eq!(parsed.name, action.name);
|
|
assert_eq!(parsed.prompt, action.prompt);
|
|
assert_eq!(parsed.icon, action.icon);
|
|
assert_eq!(parsed.is_default, action.is_default);
|
|
}
|
|
|
|
#[test]
|
|
fn test_quick_action_clone() {
|
|
let original = create_test_action("clone-test", "Clone Test", true);
|
|
let cloned = original.clone();
|
|
|
|
assert_eq!(original.id, cloned.id);
|
|
assert_eq!(original.name, cloned.name);
|
|
assert_eq!(original.is_default, cloned.is_default);
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::useless_vec)]
|
|
fn test_quick_action_sorting_defaults_first() {
|
|
let mut actions = vec![
|
|
create_test_action("custom-z", "Zebra", false),
|
|
create_test_action("default-a", "Apple", true),
|
|
create_test_action("custom-a", "Alpha", false),
|
|
create_test_action("default-z", "Zulu", true),
|
|
];
|
|
|
|
// Sort by: defaults first, then alphabetically by name
|
|
actions.sort_by(|a, b| {
|
|
let default_cmp = b.is_default.cmp(&a.is_default);
|
|
if default_cmp == std::cmp::Ordering::Equal {
|
|
a.name.cmp(&b.name)
|
|
} else {
|
|
default_cmp
|
|
}
|
|
});
|
|
|
|
// Defaults should come first
|
|
assert!(actions[0].is_default);
|
|
assert!(actions[1].is_default);
|
|
assert!(!actions[2].is_default);
|
|
assert!(!actions[3].is_default);
|
|
|
|
// Within defaults, alphabetically sorted
|
|
assert_eq!(actions[0].name, "Apple");
|
|
assert_eq!(actions[1].name, "Zulu");
|
|
|
|
// Within non-defaults, alphabetically sorted
|
|
assert_eq!(actions[2].name, "Alpha");
|
|
assert_eq!(actions[3].name, "Zebra");
|
|
}
|
|
|
|
#[test]
|
|
fn test_known_default_actions() {
|
|
let defaults = get_default_quick_actions();
|
|
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
|
|
|
|
assert!(ids.contains(&"default-review-pr"));
|
|
assert!(ids.contains(&"default-run-tests"));
|
|
assert!(ids.contains(&"default-explain-file"));
|
|
assert!(ids.contains(&"default-fix-error"));
|
|
assert!(ids.contains(&"default-write-tests"));
|
|
assert!(ids.contains(&"default-refactor"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_action_icons() {
|
|
let defaults = get_default_quick_actions();
|
|
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
|
|
|
|
assert!(icons.contains(&"git-pull-request"));
|
|
assert!(icons.contains(&"play"));
|
|
assert!(icons.contains(&"file-text"));
|
|
assert!(icons.contains(&"alert-circle"));
|
|
assert!(icons.contains(&"check-square"));
|
|
assert!(icons.contains(&"refresh-cw"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_quick_action_prompts_not_empty() {
|
|
let defaults = get_default_quick_actions();
|
|
for action in defaults {
|
|
assert!(
|
|
action.prompt.len() > 10,
|
|
"Prompt should be meaningful: {}",
|
|
action.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_quick_action_timestamps() {
|
|
let action = create_test_action("time-test", "Time Test", false);
|
|
assert!(action.created_at <= action.updated_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_actions_have_same_timestamps() {
|
|
let defaults = get_default_quick_actions();
|
|
// All defaults are created at the same instant
|
|
let first_created = defaults[0].created_at;
|
|
let first_updated = defaults[0].updated_at;
|
|
|
|
for action in &defaults {
|
|
assert_eq!(action.created_at, first_created);
|
|
assert_eq!(action.updated_at, first_updated);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_retain_non_default() {
|
|
let mut actions = vec![
|
|
create_test_action("default-1", "Default 1", true),
|
|
create_test_action("custom-1", "Custom 1", false),
|
|
create_test_action("default-2", "Default 2", true),
|
|
create_test_action("custom-2", "Custom 2", false),
|
|
];
|
|
|
|
// Mimics reset_default_quick_actions behavior (retain non-defaults)
|
|
actions.retain(|a| !a.is_default);
|
|
|
|
assert_eq!(actions.len(), 2);
|
|
assert!(actions.iter().all(|a| !a.is_default));
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::useless_vec)]
|
|
fn test_action_find_by_id() {
|
|
let actions = vec![
|
|
create_test_action("action-1", "First", false),
|
|
create_test_action("action-2", "Second", false),
|
|
create_test_action("action-3", "Third", false),
|
|
];
|
|
|
|
let found = actions.iter().find(|a| a.id == "action-2");
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().name, "Second");
|
|
|
|
let not_found = actions.iter().find(|a| a.id == "action-999");
|
|
assert!(not_found.is_none());
|
|
}
|
|
}
|