pub mod analyze;
pub mod captions;
pub mod capture;
pub mod deliver;
pub mod design;
pub mod glossary;
pub mod interact;
pub mod plan;
pub mod render;
pub mod scan;
pub mod seed;
pub mod validate;
pub mod warm;
use std::collections::HashMap;
use std::path::Path;
use std::time::SystemTime;
use rmcp::RoleServer;
use rmcp::model::{LoggingLevel, LoggingMessageNotificationParam};
use rmcp::service::Peer;
use tokio::sync::Mutex;
use crate::error::AppShotsError;
use crate::io::FileStore;
use crate::model::config::{LocaleMetadata, ProjectConfig};
use crate::model::locale::AsoLocale;
use crate::service::config_parser;
pub(crate) struct CachedConfig {
pub(crate) config: ProjectConfig,
pub(crate) modified: SystemTime,
}
pub(crate) struct ProjectCache {
pub(crate) config: Option<CachedConfig>,
pub(crate) metadata: HashMap<AsoLocale, LocaleMetadata>,
}
impl ProjectCache {
pub(crate) fn new() -> Self {
Self {
config: None,
metadata: HashMap::new(),
}
}
}
#[allow(dead_code)]
pub(crate) async fn mcp_log(peer: Option<&Peer<RoleServer>>, level: LoggingLevel, msg: &str) {
let Some(peer) = peer else { return };
let param =
LoggingMessageNotificationParam::new(level, serde_json::json!(msg)).with_logger("appshots");
let _ = peer.notify_logging_message(param).await;
}
pub(crate) fn load_project_fonts(store: &dyn FileStore, project_dir: &Path) -> Vec<Vec<u8>> {
let fonts_dir = project_dir.join("appshots/fonts");
let entries = match store.list_dir(&fonts_dir) {
Ok(e) => e,
Err(_) => return vec![],
};
entries
.iter()
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("ttf" | "otf" | "ttc" | "woff2")
)
})
.filter_map(|p| store.read_bytes(p).ok())
.collect()
}
pub(crate) async fn resolve_config(
store: &dyn FileStore,
cache: &Mutex<ProjectCache>,
config_path: &Path,
) -> Result<ProjectConfig, AppShotsError> {
{
let guard = cache.lock().await;
if let Some(ref cached) = guard.config
&& let Ok(current_mtime) = store.modified_time(config_path)
&& current_mtime == cached.modified
{
return Ok(cached.config.clone());
}
}
if !store.exists(config_path) {
return Err(AppShotsError::ConfigNotFound {
path: config_path.to_path_buf(),
});
}
let raw = store.read(config_path)?;
let config = config_parser::parse_config(&raw)?;
let mtime = store.modified_time(config_path)?;
let mut guard = cache.lock().await;
guard.config = Some(CachedConfig {
config: config.clone(),
modified: mtime,
});
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::memory::MemoryStore;
use std::path::PathBuf;
fn minimal_config_json() -> &'static str {
r#"{
"bundleId": "com.example.app",
"screens": [],
"templateMode": "single",
"devices": ["iPhone 6.9\""]
}"#
}
#[test]
fn project_cache_new_is_empty() {
let cache = ProjectCache::new();
assert!(cache.config.is_none());
assert!(cache.metadata.is_empty());
}
#[tokio::test]
async fn resolve_config_reads_from_store_and_caches() {
let store = MemoryStore::new();
let path = PathBuf::from("/project/appshots.json");
store.write(&path, minimal_config_json()).unwrap();
let cache = Mutex::new(ProjectCache::new());
let config = resolve_config(&store, &cache, &path).await.unwrap();
assert_eq!(config.bundle_id, "com.example.app");
let guard = cache.lock().await;
assert!(guard.config.is_some());
assert_eq!(
guard.config.as_ref().unwrap().config.bundle_id,
"com.example.app"
);
}
#[tokio::test]
async fn resolve_config_returns_cached_on_same_mtime() {
let store = MemoryStore::new();
let path = PathBuf::from("/project/appshots.json");
store.write(&path, minimal_config_json()).unwrap();
let cache = Mutex::new(ProjectCache::new());
let mtime = store.modified_time(&path).unwrap();
let config = config_parser::parse_config(minimal_config_json()).unwrap();
{
let mut guard = cache.lock().await;
guard.config = Some(CachedConfig {
config: ProjectConfig {
bundle_id: "cached-value".into(),
..config
},
modified: mtime,
});
}
let result = resolve_config(&store, &cache, &path).await.unwrap();
assert_eq!(result.bundle_id, "cached-value");
}
#[tokio::test]
async fn resolve_config_refreshes_on_mtime_change() {
let store = MemoryStore::new();
let path = PathBuf::from("/project/appshots.json");
store.write(&path, minimal_config_json()).unwrap();
let cache = Mutex::new(ProjectCache::new());
let config = config_parser::parse_config(minimal_config_json()).unwrap();
{
let mut guard = cache.lock().await;
guard.config = Some(CachedConfig {
config: ProjectConfig {
bundle_id: "stale-value".into(),
..config
},
modified: SystemTime::UNIX_EPOCH, });
}
let result = resolve_config(&store, &cache, &path).await.unwrap();
assert_eq!(result.bundle_id, "com.example.app");
}
#[tokio::test]
async fn resolve_config_returns_config_not_found_for_missing_file() {
let store = MemoryStore::new();
let path = PathBuf::from("/project/appshots.json");
let cache = Mutex::new(ProjectCache::new());
let err = resolve_config(&store, &cache, &path).await.unwrap_err();
assert!(matches!(err, AppShotsError::ConfigNotFound { .. }));
}
#[tokio::test]
async fn mcp_log_with_none_peer_is_noop() {
mcp_log(None, LoggingLevel::Info, "test message").await;
}
#[test]
fn load_project_fonts_returns_font_bytes() {
let store = MemoryStore::new();
let project_dir = PathBuf::from("/project");
store
.write(Path::new("/project/appshots/fonts/custom.ttf"), "fake-ttf")
.unwrap();
store
.write(Path::new("/project/appshots/fonts/bold.otf"), "fake-otf")
.unwrap();
let fonts = load_project_fonts(&store, &project_dir);
assert_eq!(fonts.len(), 2);
}
#[test]
fn load_project_fonts_ignores_non_font_files() {
let store = MemoryStore::new();
let project_dir = PathBuf::from("/project");
store
.write(
Path::new("/project/appshots/fonts/readme.txt"),
"not a font",
)
.unwrap();
store
.write(Path::new("/project/appshots/fonts/custom.ttf"), "fake")
.unwrap();
let fonts = load_project_fonts(&store, &project_dir);
assert_eq!(fonts.len(), 1);
}
#[test]
fn load_project_fonts_empty_when_no_dir() {
let store = MemoryStore::new();
let project_dir = PathBuf::from("/project");
let fonts = load_project_fonts(&store, &project_dir);
assert!(fonts.is_empty());
}
}