Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions crates/commit-cat-core/src/models/cat_profile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};

pub const DEFAULT_CAT_PROFILE_ID: &str = "default";
pub const DEFAULT_CAT_PROFILE_NAME: &str = "My Cat";

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum CatColor {
White,
Brown,
Orange,
}

impl Default for CatColor {
fn default() -> Self {
Self::Brown
}
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum CatPersonalityPreset {
Classic,
Chill,
Tsundere,
Chaotic,
}

impl Default for CatPersonalityPreset {
fn default() -> Self {
Self::Classic
}
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CatProfile {
#[serde(default = "default_profile_id")]
pub id: String,
#[serde(default = "default_profile_name")]
pub name: String,
#[serde(default)]
pub color: CatColor,
#[serde(default)]
pub personality: CatPersonalityPreset,
}

impl Default for CatProfile {
fn default() -> Self {
default_cat_profile()
}
}

pub fn default_profile_id() -> String {
DEFAULT_CAT_PROFILE_ID.to_string()
}

pub fn default_profile_name() -> String {
DEFAULT_CAT_PROFILE_NAME.to_string()
}

pub fn default_cat_profile() -> CatProfile {
CatProfile {
id: default_profile_id(),
name: default_profile_name(),
color: CatColor::Brown,
personality: CatPersonalityPreset::Classic,
}
}

pub fn normalize_cat_profiles(profiles: &mut Vec<CatProfile>, active_profile_id: &mut String) {
if profiles.is_empty() {
profiles.push(default_cat_profile());
}

for profile in profiles.iter_mut() {
if profile.id.trim().is_empty() {
profile.id = default_profile_id();
}
profile.name = normalize_profile_name(&profile.name);
}

let mut deduped = Vec::with_capacity(profiles.len());
let mut seen_ids = std::collections::HashSet::new();
for profile in profiles.drain(..) {
if seen_ids.insert(profile.id.clone()) {
deduped.push(profile);
}
}
*profiles = deduped;

if profiles.is_empty() {
profiles.push(default_cat_profile());
}

if active_profile_id.trim().is_empty()
|| !profiles
.iter()
.any(|profile| profile.id == *active_profile_id)
{
*active_profile_id = profiles[0].id.clone();
}
}

pub fn normalize_profile_name(name: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
default_profile_name()
} else {
trimmed.to_string()
}
}
1 change: 1 addition & 0 deletions crates/commit-cat-core/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod activity;
pub mod ai_provider_catalog;
pub mod cat;
pub mod cat_profile;
pub mod growth;
pub mod settings;
60 changes: 60 additions & 0 deletions crates/commit-cat-core/src/models/settings.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use super::cat_profile::{default_cat_profile, normalize_cat_profiles, CatColor, CatProfile};

/// 앱 설정 (권한 토글 포함)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -72,6 +74,9 @@ pub struct AppSettings {
/// 생일 일 (1-31)
#[serde(default)]
pub birthday_day: Option<u32>,
/// deprecated: 프로필 도입 전 저장되었을 수 있는 레거시 고양이 색상
#[serde(default, rename = "catColor", skip_serializing)]
pub legacy_cat_color: Option<CatColor>,
/// 클라우드 싱크 JWT 토큰
#[serde(default)]
pub cloud_token: Option<String>,
Expand Down Expand Up @@ -123,6 +128,7 @@ impl Default for AppSettings {
sub_cats_enabled: None,
birthday_month: None,
birthday_day: None,
legacy_cat_color: None,
cloud_token: None,
cloud_server_url: None,
device_id: None,
Expand Down Expand Up @@ -152,6 +158,10 @@ pub struct AppData {
pub version: u32,
pub settings: AppSettings,
pub cat: CatPersistence,
#[serde(default = "default_cat_profiles")]
pub cat_profiles: Vec<CatProfile>,
#[serde(default = "default_active_cat_profile_id")]
pub active_cat_profile_id: String,
pub today: super::activity::DailySummary,
pub history: Vec<super::activity::DailySummary>,
#[serde(default)]
Expand Down Expand Up @@ -211,10 +221,60 @@ impl Default for AppData {
version: 1,
settings: AppSettings::default(),
cat: CatPersistence::default(),
cat_profiles: default_cat_profiles(),
active_cat_profile_id: default_active_cat_profile_id(),
today: super::activity::DailySummary::default(),
history: vec![],
github_state: GitHubState::default(),
last_update_check: None,
}
}
}

fn default_cat_profiles() -> Vec<CatProfile> {
vec![default_cat_profile()]
}

fn default_active_cat_profile_id() -> String {
default_cat_profile().id
}

impl AppData {
pub fn normalize(&mut self) {
normalize_cat_profiles(&mut self.cat_profiles, &mut self.active_cat_profile_id);

if let Some(legacy_color) = self.settings.legacy_cat_color.take() {
let default_profile = default_cat_profile();
if self.cat_profiles.len() == 1 {
let profile = &mut self.cat_profiles[0];
let is_default_profile = profile.id == default_profile.id
&& profile.name == default_profile.name
&& profile.color == default_profile.color
&& profile.personality == default_profile.personality
&& self.active_cat_profile_id == profile.id;
if is_default_profile {
profile.color = legacy_color;
}
}
}
}

pub fn active_cat_profile(&self) -> Option<&CatProfile> {
self.cat_profiles
.iter()
.find(|profile| profile.id == self.active_cat_profile_id)
.or_else(|| self.cat_profiles.first())
}

pub fn active_cat_profile_mut(&mut self) -> Option<&mut CatProfile> {
if let Some(index) = self
.cat_profiles
.iter()
.position(|profile| profile.id == self.active_cat_profile_id)
{
self.cat_profiles.get_mut(index)
} else {
self.cat_profiles.first_mut()
}
}
}
67 changes: 67 additions & 0 deletions crates/commit-cat-core/tests/cat_profile_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use commit_cat_core::models::cat_profile::{
normalize_cat_profiles, CatColor, CatPersonalityPreset, CatProfile, DEFAULT_CAT_PROFILE_ID,
};
use commit_cat_core::models::settings::AppData;

#[test]
fn normalize_profiles_creates_default_when_missing() {
let mut profiles = Vec::new();
let mut active_profile_id = String::new();

normalize_cat_profiles(&mut profiles, &mut active_profile_id);

assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].id, DEFAULT_CAT_PROFILE_ID);
assert_eq!(profiles[0].name, "My Cat");
assert_eq!(profiles[0].color, CatColor::Brown);
assert_eq!(profiles[0].personality, CatPersonalityPreset::Classic);
assert_eq!(active_profile_id, DEFAULT_CAT_PROFILE_ID);
}

#[test]
fn normalize_profiles_repairs_invalid_active_id() {
let mut data = AppData::default();
data.cat_profiles = vec![
CatProfile {
id: "alpha".to_string(),
name: "Alpha".to_string(),
color: CatColor::White,
personality: CatPersonalityPreset::Chill,
},
CatProfile {
id: "beta".to_string(),
name: "Beta".to_string(),
color: CatColor::Orange,
personality: CatPersonalityPreset::Chaotic,
},
];
data.active_cat_profile_id = "missing".to_string();

data.normalize();

assert_eq!(data.active_cat_profile_id, "alpha");
}

#[test]
fn normalize_profiles_applies_legacy_color_to_default_profile() {
let mut data = AppData::default();
data.settings.legacy_cat_color = Some(CatColor::White);

data.normalize();

assert_eq!(
data.active_cat_profile().map(|profile| profile.color),
Some(CatColor::White)
);
assert_eq!(data.settings.legacy_cat_color, None);
}

#[test]
fn active_profile_lookup_is_safe_when_profiles_are_empty() {
let mut data = AppData::default();
data.cat_profiles.clear();
data.active_cat_profile_id.clear();

assert!(data.active_cat_profile().is_none());
assert!(data.active_cat_profile_mut().is_none());
}
3 changes: 2 additions & 1 deletion src-tauri/src/commands/activity.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use commit_cat_core::models::activity::{ActivityEvent, CodingStatus, DailySummary};
use crate::services::storage;
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
use tauri::Manager;

/// 오늘 활동 요약 (이벤트에서 계산)
#[tauri::command]
Expand Down
20 changes: 19 additions & 1 deletion src-tauri/src/commands/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use commit_cat_core::models::ai_provider_catalog::{
normalize_provider_owned, resolve_model_with_catalog, resolve_reasoning_with_catalog,
AiProviderCatalogResponse,
};
use commit_cat_core::models::cat_profile::{default_cat_profile, CatPersonalityPreset};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
Expand Down Expand Up @@ -92,17 +93,34 @@ pub async fn chat_with_cat(app: AppHandle, message: String) -> Result<String, St
// Build system prompt (shared)
let today = &data.today;
let cat = &data.cat;
let active_profile = data
.active_cat_profile()
.cloned()
.unwrap_or_else(default_cat_profile);
let coding_hours = today.coding_minutes / 60;
let coding_mins = today.coding_minutes % 60;
let persona_clause = match active_profile.personality {
CatPersonalityPreset::Classic => {
"Personality: balanced, warm, playful, and gently encouraging."
}
CatPersonalityPreset::Chill => "Personality: calm, cozy, reassuring, and low-pressure.",
CatPersonalityPreset::Tsundere => {
"Personality: a little sharp and teasing, but secretly supportive and affectionate."
}
CatPersonalityPreset::Chaotic => {
"Personality: hyper, playful, dramatic, and full of restless coding energy."
}
};

let system_prompt = format!(
"You are CommitCat, a small desktop cat companion that lives on a developer's screen. \
Respond with short, warm messages. Keep it cute but not over the top — \
no action descriptions like *purrs* or *meows*. \
Reply in 3-5 sentences max. Always add 1 relevant emoji at the end. \
Keep total response under 200 characters. \
Active cat profile: {}. {} \
User stats: {} commits, {}h{}m coding, Lv.{}.",
today.commits, coding_hours, coding_mins, cat.level,
active_profile.name, persona_clause, today.commits, coding_hours, coding_mins, cat.level,
);

match provider.as_str() {
Expand Down
Loading