Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/adapters/claudecode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ impl ClaudeCodeAdapter {
0
};

let mut session = Session::new(&session_id, name, self.id());
let mut session = Session::new(&session_id, name, self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;
session.message_count = message_count;
Expand Down Expand Up @@ -189,7 +189,7 @@ impl Adapter for ClaudeCodeAdapter {
let name = first_user_text.unwrap_or_else(|| id.clone());
let (created_at, updated_at) = file_times(&path).await;

let mut session = Session::new(&id, name, self.id());
let mut session = Session::new(&id, name, self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;
session.message_count = message_count;
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ impl CodexAdapter {
0
};

let mut session = Session::new(&session_id, name, self.id());
let mut session = Session::new(&session_id, name, self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;
session.message_count = message_count;
Expand Down Expand Up @@ -303,7 +303,7 @@ impl Adapter for CodexAdapter {
None => (updated_at, 0),
};

let mut session = Session::new(&row.id, name, self.id());
let mut session = Session::new(&row.id, name, self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;
session.message_count = message_count;
Expand Down
1 change: 1 addition & 0 deletions src/adapters/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ impl Adapter for CursorAdapter {
&id,
title.unwrap_or_else(|| "Untitled".to_string()),
"cursor",
created_at,
);
session.created_at = created_at;
session.updated_at = updated_at;
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ impl Adapter for GeminiAdapter {
})
.unwrap_or_else(Utc::now);

let mut session = Session::new(&session_id, session_id.clone(), self.id());
let mut session = Session::new(&session_id, session_id.clone(), self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;

Expand Down
2 changes: 1 addition & 1 deletion src/adapters/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ impl Adapter for OpenCodeAdapter {
// Count messages in the session
let message_count = self.count_messages(&session_id).await.unwrap_or(0);

let mut session = Session::new(&session_id, session_id.clone(), self.id());
let mut session = Session::new(&session_id, session_id.clone(), self.id(), created_at);
session.created_at = created_at;
session.updated_at = updated_at;
session.message_count = message_count;
Expand Down
32 changes: 20 additions & 12 deletions src/core/logic/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,18 +291,19 @@ fn parse_criterion_line(line: &str) -> Option<Criterion> {
/// "#;
///
/// let doc = parse_spec_document(content).unwrap();
/// let intent = build_intent_from_spec(doc, PathBuf::from("auth.md")).unwrap();
/// let intent = build_intent_from_spec(doc, PathBuf::from("auth.md"), Some("intent-abc".to_string())).unwrap();
///
/// assert_eq!(intent.title, "Add JWT Authentication");
/// assert_eq!(intent.acceptance_criteria.len(), 3);
/// // Frontmatter id takes precedence over the caller-provided fallback.
/// assert_eq!(intent.id, "intent-123");
/// ```
pub fn build_intent_from_spec(
doc: SpecDocument,
spec_path: PathBuf,
fallback_id: Option<String>,
) -> Result<Intent, IntentParseError> {
let title = extract_title(&doc.content)
.or_else(|| Some("Untitled Intent".to_string()))
.unwrap();
let title = extract_title(&doc.content).unwrap_or_else(|| "Untitled Intent".to_string());

let description = extract_description(&doc.content).unwrap_or_default();
let acceptance_criteria = extract_acceptance_criteria(&doc.content);
Expand All @@ -314,11 +315,17 @@ pub fn build_intent_from_spec(
.or_else(|| doc.frontmatter.created.clone())
.unwrap_or_else(|| "2026-01-01T00:00:00Z".to_string());

// The id is resolved in priority order: frontmatter → caller-provided
// fallback → error. The Core never generates ids itself.
let id = doc
.frontmatter
.id
.clone()
.or(fallback_id)
.ok_or_else(|| IntentParseError::MissingField("id".to_string()))?;

Ok(Intent {
id: doc
.frontmatter
.id
.unwrap_or_else(|| format!("intent-{}", uuid::Uuid::new_v4())),
id,
title,
description,
status: doc.frontmatter.status.unwrap_or(IntentStatus::Draft),
Expand Down Expand Up @@ -348,12 +355,13 @@ pub fn build_intent_from_spec(
/// ```
/// use rightclick::core::logic::intent::generate_default_spec;
///
/// let content = generate_default_spec("Add Feature X", "2026-02-14T10:00:00Z");
/// let content = generate_default_spec("Add Feature X", "2026-02-14T10:00:00Z", "abc123");
/// assert!(content.contains("Add Feature X"));
/// assert!(content.contains("## Description"));
/// assert!(content.contains("## Acceptance Criteria"));
/// assert!(content.contains("id: intent-abc123"));
/// ```
pub fn generate_default_spec(title: &str, now: &str) -> String {
pub fn generate_default_spec(title: &str, now: &str, id_suffix: &str) -> String {
format!(
r#"---
id: intent-{id}
Expand Down Expand Up @@ -385,7 +393,7 @@ Describe what needs to be implemented...

Additional notes and considerations...
"#,
id = uuid::Uuid::new_v4(),
id = id_suffix,
now = now,
title = title
)
Expand Down Expand Up @@ -607,7 +615,7 @@ More content.

#[test]
fn test_generate_default_spec() {
let spec = generate_default_spec("Test Feature", "2026-02-14T10:00:00Z");
let spec = generate_default_spec("Test Feature", "2026-02-14T10:00:00Z", "test-suffix");

assert!(spec.contains("# Test Feature"));
assert!(spec.contains("## Description"));
Expand Down
7 changes: 7 additions & 0 deletions src/core/logic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@
pub mod guards;
pub mod intent;
pub mod navigation;
pub mod project;

// Re-export commonly used types for convenience
pub use guards::check_guard;
pub use navigation::{apply_navigation, calculate_navigation};

// Re-export project logic
pub use project::{
ProjectPathError, build_project_config, extract_project_name, find_existing_project,
validate_project_path,
};

// Re-export intent functions and types
pub use intent::{
IntentParseError, build_intent_from_spec, extract_acceptance_criteria,
Expand Down
222 changes: 222 additions & 0 deletions src/core/logic/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//! Project logic - pure functions for project validation and construction.
//!
//! These functions have no side effects (filesystem existence checks are
//! referentially transparent queries with no observable mutation) and are
//! fully deterministic, in accordance with the Functional Core pattern.
Comment on lines +1 to +5

use std::path::{Path, PathBuf};

use crate::core::models::config::ProjectConfig;

/// Errors that can occur when validating a project path.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ProjectPathError {
/// The path does not exist on the filesystem.
#[error("path does not exist: {0}")]
NotFound(PathBuf),
/// The path exists but is not a directory.
#[error("path is not a directory: {0}")]
NotADirectory(PathBuf),
}

/// Validate that the given path is suitable as a project root.
///
/// Returns `Ok(())` only if the path exists and is a directory.
///
/// # Examples
///
/// ```
/// use rightclick::core::logic::project::validate_project_path;
/// use std::path::Path;
///
/// // A non-existent path is rejected.
/// let err = validate_project_path(Path::new("/definitely/not/here/xyz")).unwrap_err();
/// assert!(matches!(
/// err,
/// rightclick::core::logic::project::ProjectPathError::NotFound(_)
/// ));
/// ```
pub fn validate_project_path(path: &Path) -> Result<(), ProjectPathError> {
if !path.exists() {
return Err(ProjectPathError::NotFound(path.to_path_buf()));
}
if !path.is_dir() {
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep filesystem validation in the imperative shell

Path::exists and Path::is_dir perform filesystem I/O, and the result can change for the same path as external state changes, so this new Core function is neither pure nor deterministic. Keep the metadata checks in AppUsecase::load_project and pass already-observed facts into Core validation if pure business validation is needed.

AGENTS.md reference: AGENTS.md:L39-L42

Useful? React with 👍 / 👎.

return Err(ProjectPathError::NotADirectory(path.to_path_buf()));
}
Ok(())
}

/// Extract a human-readable project name from the final path component.
///
/// Returns `None` if the path has no final component (e.g. `/`).
///
/// # Examples
///
/// ```
/// use rightclick::core::logic::project::extract_project_name;
/// use std::path::Path;
///
/// assert_eq!(extract_project_name(Path::new("/home/user/my-app")), Some("my-app"));
/// ```
pub fn extract_project_name(path: &Path) -> Option<&str> {
path.file_name().and_then(|n| n.to_str())
}

/// Find an existing project whose path matches the given one.
///
/// Pure lookup over a slice of [`ProjectConfig`]. Canonical comparison is
/// delegated to `Path` equality semantics (no normalization is performed).
///
/// # Examples
///
/// ```
/// use rightclick::core::logic::project::find_existing_project;
/// use rightclick::core::models::config::ProjectConfig;
/// use std::path::Path;
///
/// let projects = vec![
/// ProjectConfig {
/// id: "p1".to_string(),
/// name: "alpha".to_string(),
/// path: "/tmp/alpha".to_string(),
/// description: None,
/// favorite: false,
/// tags: vec![],
/// },
/// ];
/// let found = find_existing_project(&projects, Path::new("/tmp/alpha"));
/// assert!(found.is_some());
/// assert_eq!(found.unwrap().id, "p1");
/// ```
pub fn find_existing_project<'a>(
projects: &'a [ProjectConfig],
path: &Path,
) -> Option<&'a ProjectConfig> {
projects.iter().find(|p| Path::new(&p.path) == path)
}

/// Build a fresh [`ProjectConfig`] from a generated id and a filesystem path.
///
/// The project name is derived from the final path component, falling back to
/// `"unnamed"` when no usable component is available.
///
/// # Examples
///
/// ```
/// use rightclick::core::logic::project::build_project_config;
/// use std::path::Path;
///
/// let cfg = build_project_config("id-123".to_string(), Path::new("/tmp/widget"));
/// assert_eq!(cfg.id, "id-123");
/// assert_eq!(cfg.name, "widget");
/// assert_eq!(cfg.path, "/tmp/widget");
/// assert!(!cfg.favorite);
/// ```
pub fn build_project_config(id: String, path: &Path) -> ProjectConfig {
let name = extract_project_name(path).unwrap_or("unnamed").to_string();
ProjectConfig {
id,
name,
path: path.to_string_lossy().to_string(),
description: None,
favorite: false,
tags: Vec::new(),
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

fn sample_config(id: &str, path: &str) -> ProjectConfig {
ProjectConfig {
id: id.to_string(),
name: format!("name-{}", id),
path: path.to_string(),
description: None,
favorite: false,
tags: Vec::new(),
}
}

#[test]
fn validate_rejects_missing_path() {
let err = validate_project_path(Path::new("/this/should/not/exist/abczyx")).unwrap_err();
assert!(matches!(err, ProjectPathError::NotFound(_)));
}

#[test]
fn validate_rejects_file_for_directory() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("file.txt");
std::fs::write(&file_path, "x").unwrap();
let err = validate_project_path(&file_path).unwrap_err();
assert!(matches!(err, ProjectPathError::NotADirectory(_)));
}

#[test]
fn validate_accepts_existing_directory() {
let tmp = TempDir::new().unwrap();
validate_project_path(tmp.path()).expect("temp dir should be valid");
}

#[test]
fn extract_name_from_path() {
assert_eq!(
extract_project_name(Path::new("/home/user/widget")),
Some("widget")
);
assert_eq!(
extract_project_name(Path::new("relative")),
Some("relative")
);
}

#[test]
fn extract_name_handles_root() {
// On POSIX root has no file_name
assert_eq!(extract_project_name(Path::new("/")), None);
}

#[test]
fn find_existing_returns_match() {
let projects = vec![
sample_config("p1", "/tmp/alpha"),
sample_config("p2", "/tmp/beta"),
];
let found = find_existing_project(&projects, Path::new("/tmp/beta"));
assert!(found.is_some());
assert_eq!(found.unwrap().id, "p2");
}

#[test]
fn find_existing_returns_none_when_absent() {
let projects = vec![sample_config("p1", "/tmp/alpha")];
assert!(find_existing_project(&projects, Path::new("/tmp/missing")).is_none());
}

#[test]
fn build_config_extracts_name() {
let cfg = build_project_config("id-1".to_string(), Path::new("/var/projects/widget"));
assert_eq!(cfg.id, "id-1");
assert_eq!(cfg.name, "widget");
assert_eq!(cfg.path, "/var/projects/widget");
assert!(cfg.description.is_none());
assert!(!cfg.favorite);
assert!(cfg.tags.is_empty());
}

#[test]
fn build_config_falls_back_to_unnamed_for_root() {
let cfg = build_project_config("id-2".to_string(), Path::new("/"));
assert_eq!(cfg.name, "unnamed");
}

#[test]
fn build_config_is_deterministic() {
let a = build_project_config("id".to_string(), Path::new("/tmp/x"));
let b = build_project_config("id".to_string(), Path::new("/tmp/x"));
assert_eq!(a, b);
}
}
Loading
Loading