Skip to content

Commit 94e2691

Browse files
authored
Merge pull request #92 from codervisor/copilot/implement-spec-169
Implement spec 169: Rust/Tauri backend for desktop spec operations
2 parents a9c22be + 443b288 commit 94e2691

File tree

13 files changed

+2433
-22
lines changed

13 files changed

+2433
-22
lines changed

packages/desktop/src-tauri/src/main.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod commands;
44
mod config;
55
mod projects;
66
mod shortcuts;
7+
mod specs;
78
mod state;
89
mod tray;
910
mod ui_server;
@@ -22,6 +23,19 @@ use commands::{
2223
desktop_remove_project,
2324
desktop_rename_project,
2425
};
26+
use specs::{
27+
get_specs,
28+
get_spec_detail,
29+
get_project_stats,
30+
get_dependency_graph,
31+
get_spec_dependencies_cmd,
32+
search_specs,
33+
get_specs_by_status,
34+
get_all_tags,
35+
validate_spec_cmd,
36+
validate_all_specs_cmd,
37+
update_spec_status,
38+
};
2539
use shortcuts::register_shortcuts;
2640
use state::DesktopState;
2741

@@ -53,6 +67,7 @@ fn main() {
5367
Ok(())
5468
})
5569
.invoke_handler(tauri::generate_handler![
70+
// Desktop commands
5671
desktop_bootstrap,
5772
desktop_refresh_projects,
5873
desktop_switch_project,
@@ -62,7 +77,19 @@ fn main() {
6277
desktop_validate_project,
6378
desktop_toggle_favorite,
6479
desktop_remove_project,
65-
desktop_rename_project
80+
desktop_rename_project,
81+
// Spec commands (Phase 1 & 2 of spec 169)
82+
get_specs,
83+
get_spec_detail,
84+
get_project_stats,
85+
get_dependency_graph,
86+
get_spec_dependencies_cmd,
87+
search_specs,
88+
get_specs_by_status,
89+
get_all_tags,
90+
validate_spec_cmd,
91+
validate_all_specs_cmd,
92+
update_spec_status
6693
])
6794
.run(tauri::generate_context!())
6895
.expect("error while running LeanSpec Desktop");
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
//! Tauri commands for spec operations
2+
//!
3+
//! These commands expose the Rust spec operations library to the UI frontend,
4+
//! replacing the need for Next.js API routes.
5+
6+
use std::path::Path;
7+
use tauri::State;
8+
9+
use crate::specs::{
10+
constants::VALID_STATUSES,
11+
reader::{LightweightSpec, Spec, SpecReader},
12+
stats::{calculate_stats, StatsResult},
13+
dependencies::{build_dependency_graph, get_spec_dependencies, DependencyGraph, SpecDependencies},
14+
validation::{validate_all_specs, validate_spec, ValidationResult},
15+
};
16+
use crate::state::DesktopState;
17+
18+
/// Get all specs for a project
19+
#[tauri::command]
20+
pub async fn get_specs(
21+
state: State<'_, DesktopState>,
22+
project_id: String,
23+
) -> Result<Vec<LightweightSpec>, String> {
24+
let project = state
25+
.project_store
26+
.find(&project_id)
27+
.ok_or_else(|| "Project not found".to_string())?;
28+
29+
let reader = SpecReader::new(&project.specs_dir, &project_id);
30+
let specs = reader.load_all();
31+
32+
Ok(specs.iter().map(LightweightSpec::from).collect())
33+
}
34+
35+
/// Get a single spec by ID or number
36+
#[tauri::command]
37+
pub async fn get_spec_detail(
38+
state: State<'_, DesktopState>,
39+
project_id: String,
40+
spec_id: String,
41+
) -> Result<Spec, String> {
42+
let project = state
43+
.project_store
44+
.find(&project_id)
45+
.ok_or_else(|| "Project not found".to_string())?;
46+
47+
let reader = SpecReader::new(&project.specs_dir, &project_id);
48+
reader
49+
.load_spec(&spec_id)
50+
.ok_or_else(|| format!("Spec '{}' not found", spec_id))
51+
}
52+
53+
/// Get project statistics
54+
#[tauri::command]
55+
pub async fn get_project_stats(
56+
state: State<'_, DesktopState>,
57+
project_id: String,
58+
) -> Result<StatsResult, String> {
59+
let project = state
60+
.project_store
61+
.find(&project_id)
62+
.ok_or_else(|| "Project not found".to_string())?;
63+
64+
let reader = SpecReader::new(&project.specs_dir, &project_id);
65+
let specs = reader.load_all();
66+
67+
Ok(calculate_stats(&specs))
68+
}
69+
70+
/// Get dependency graph for visualization
71+
#[tauri::command]
72+
pub async fn get_dependency_graph(
73+
state: State<'_, DesktopState>,
74+
project_id: String,
75+
) -> Result<DependencyGraph, String> {
76+
let project = state
77+
.project_store
78+
.find(&project_id)
79+
.ok_or_else(|| "Project not found".to_string())?;
80+
81+
let reader = SpecReader::new(&project.specs_dir, &project_id);
82+
let specs = reader.load_all();
83+
84+
Ok(build_dependency_graph(&specs))
85+
}
86+
87+
/// Get dependencies for a specific spec
88+
#[tauri::command]
89+
pub async fn get_spec_dependencies_cmd(
90+
state: State<'_, DesktopState>,
91+
project_id: String,
92+
spec_id: String,
93+
) -> Result<SpecDependencies, String> {
94+
let project = state
95+
.project_store
96+
.find(&project_id)
97+
.ok_or_else(|| "Project not found".to_string())?;
98+
99+
let reader = SpecReader::new(&project.specs_dir, &project_id);
100+
let specs = reader.load_all();
101+
102+
let spec = specs
103+
.iter()
104+
.find(|s| s.spec_name == spec_id || s.id == spec_id || s.id == format!("fs-{}", spec_id))
105+
.ok_or_else(|| format!("Spec '{}' not found", spec_id))?;
106+
107+
Ok(get_spec_dependencies(spec, &specs))
108+
}
109+
110+
/// Search specs by query
111+
#[tauri::command]
112+
pub async fn search_specs(
113+
state: State<'_, DesktopState>,
114+
project_id: String,
115+
query: String,
116+
) -> Result<Vec<LightweightSpec>, String> {
117+
let project = state
118+
.project_store
119+
.find(&project_id)
120+
.ok_or_else(|| "Project not found".to_string())?;
121+
122+
let reader = SpecReader::new(&project.specs_dir, &project_id);
123+
let specs = reader.search(&query);
124+
125+
Ok(specs.iter().map(LightweightSpec::from).collect())
126+
}
127+
128+
/// Get specs by status
129+
#[tauri::command]
130+
pub async fn get_specs_by_status(
131+
state: State<'_, DesktopState>,
132+
project_id: String,
133+
status: String,
134+
) -> Result<Vec<LightweightSpec>, String> {
135+
let project = state
136+
.project_store
137+
.find(&project_id)
138+
.ok_or_else(|| "Project not found".to_string())?;
139+
140+
let reader = SpecReader::new(&project.specs_dir, &project_id);
141+
let specs = reader.get_by_status(&status);
142+
143+
Ok(specs.iter().map(LightweightSpec::from).collect())
144+
}
145+
146+
/// Get all unique tags
147+
#[tauri::command]
148+
pub async fn get_all_tags(
149+
state: State<'_, DesktopState>,
150+
project_id: String,
151+
) -> Result<Vec<String>, String> {
152+
let project = state
153+
.project_store
154+
.find(&project_id)
155+
.ok_or_else(|| "Project not found".to_string())?;
156+
157+
let reader = SpecReader::new(&project.specs_dir, &project_id);
158+
Ok(reader.get_all_tags())
159+
}
160+
161+
/// Validate a single spec
162+
#[tauri::command]
163+
pub async fn validate_spec_cmd(
164+
state: State<'_, DesktopState>,
165+
project_id: String,
166+
spec_id: String,
167+
) -> Result<ValidationResult, String> {
168+
let project = state
169+
.project_store
170+
.find(&project_id)
171+
.ok_or_else(|| "Project not found".to_string())?;
172+
173+
let reader = SpecReader::new(&project.specs_dir, &project_id);
174+
let spec = reader
175+
.load_spec(&spec_id)
176+
.ok_or_else(|| format!("Spec '{}' not found", spec_id))?;
177+
178+
Ok(validate_spec(&spec))
179+
}
180+
181+
/// Validate all specs in a project
182+
#[tauri::command]
183+
pub async fn validate_all_specs_cmd(
184+
state: State<'_, DesktopState>,
185+
project_id: String,
186+
) -> Result<Vec<ValidationResult>, String> {
187+
let project = state
188+
.project_store
189+
.find(&project_id)
190+
.ok_or_else(|| "Project not found".to_string())?;
191+
192+
let reader = SpecReader::new(&project.specs_dir, &project_id);
193+
let specs = reader.load_all();
194+
195+
Ok(validate_all_specs(&specs))
196+
}
197+
198+
/// Update spec status (writes to filesystem)
199+
#[tauri::command]
200+
pub async fn update_spec_status(
201+
state: State<'_, DesktopState>,
202+
project_id: String,
203+
spec_id: String,
204+
new_status: String,
205+
) -> Result<Spec, String> {
206+
use std::fs;
207+
use chrono::Utc;
208+
209+
// Validate status
210+
if !VALID_STATUSES.contains(&new_status.as_str()) {
211+
return Err(format!(
212+
"Invalid status '{}'. Must be one of: {}",
213+
new_status,
214+
VALID_STATUSES.join(", ")
215+
));
216+
}
217+
218+
let project = state
219+
.project_store
220+
.find(&project_id)
221+
.ok_or_else(|| "Project not found".to_string())?;
222+
223+
let reader = SpecReader::new(&project.specs_dir, &project_id);
224+
let spec = reader
225+
.load_spec(&spec_id)
226+
.ok_or_else(|| format!("Spec '{}' not found", spec_id))?;
227+
228+
// Read the spec file - construct proper path from specs_dir and spec_name
229+
// The file_path is relative like "specs/169-name/README.md"
230+
// We need to join specs_dir with the spec directory and README.md
231+
let spec_path = Path::new(&project.specs_dir)
232+
.join(&spec.spec_name)
233+
.join("README.md");
234+
235+
let content = fs::read_to_string(&spec_path)
236+
.map_err(|e| format!("Failed to read spec file: {}", e))?;
237+
238+
// Update status in frontmatter
239+
let updated_content = update_frontmatter_field(&content, "status", &new_status)?;
240+
241+
// Add transition record and update updated_at
242+
let now = Utc::now().to_rfc3339();
243+
let updated_content = update_frontmatter_field(&updated_content, "updated_at", &format!("'{}'", now))?;
244+
245+
// Write back
246+
fs::write(&spec_path, &updated_content)
247+
.map_err(|e| format!("Failed to write spec file: {}", e))?;
248+
249+
// Reload and return updated spec
250+
reader
251+
.load_spec(&spec_id)
252+
.ok_or_else(|| "Failed to reload spec after update".to_string())
253+
}
254+
255+
/// Helper to update a field in YAML frontmatter
256+
fn update_frontmatter_field(content: &str, field: &str, value: &str) -> Result<String, String> {
257+
if !content.starts_with("---") {
258+
return Err("No frontmatter found".to_string());
259+
}
260+
261+
let rest = &content[3..];
262+
let rest = rest.strip_prefix('\n').unwrap_or(rest);
263+
264+
if let Some(end_pos) = rest.find("\n---") {
265+
let yaml_content = &rest[..end_pos];
266+
let markdown_content = &rest[end_pos + 4..];
267+
268+
// Simple field replacement (works for simple values)
269+
let field_pattern = format!("{}:", field);
270+
let new_line = format!("{}: {}", field, value);
271+
let mut lines: Vec<String> = yaml_content.lines().map(String::from).collect();
272+
let mut found = false;
273+
274+
for line in lines.iter_mut() {
275+
if line.trim_start().starts_with(&field_pattern) {
276+
*line = new_line.clone();
277+
found = true;
278+
break;
279+
}
280+
}
281+
282+
// If field not found, add it at the end
283+
let new_yaml = if found {
284+
lines.join("\n")
285+
} else {
286+
format!("{}\n{}: {}", yaml_content, field, value)
287+
};
288+
289+
Ok(format!("---\n{}\n---{}", new_yaml, markdown_content))
290+
} else {
291+
Err("Malformed frontmatter".to_string())
292+
}
293+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Constants shared across the specs module
2+
3+
/// Valid status values for specs
4+
pub const VALID_STATUSES: [&str; 4] = ["planned", "in-progress", "complete", "archived"];
5+
6+
/// Valid priority values for specs
7+
pub const VALID_PRIORITIES: [&str; 4] = ["critical", "high", "medium", "low"];

0 commit comments

Comments
 (0)