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
13 changes: 13 additions & 0 deletions src-tauri/migrations/006_add_projects.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects(created_at DESC);

ALTER TABLE generations ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 SQLite foreign key ON DELETE SET NULL may not be enforced without PRAGMA

The migration at src-tauri/migrations/006_add_projects.sql:11 defines REFERENCES projects(id) ON DELETE SET NULL, and the test delete_project_unassigns_generations_not_deletes_them at src-tauri/src/services/db.rs:1036 asserts that deleting a project nulls out project_id on associated generations. However, SQLite does not enforce foreign key constraints by default — it requires PRAGMA foreign_keys = ON on each connection. No such pragma is set anywhere in the codebase (searched for foreign_keys with zero results). If the pragma is not enabled, deleting a project will leave orphaned project_id values in the generations table rather than setting them to NULL. The test may pass in certain SQLite builds or test environments but could fail or behave incorrectly in production. The Database::connection() method at src-tauri/src/services/db.rs:37 would be the right place to add this pragma.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


CREATE INDEX IF NOT EXISTS idx_generations_project_id ON generations(project_id) WHERE project_id IS NOT NULL;
15 changes: 13 additions & 2 deletions src-tauri/src/cli/list.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
use crate::{cli::human_output, cli::spec::ListArgs, models::errors::AppResult};
use crate::{
cli::human_output, cli::resolve_project_by_prefix, cli::spec::ListArgs,
models::errors::AppResult,
};

use super::AppState;

pub fn execute(state: &AppState, json: bool, args: ListArgs) -> AppResult<()> {
let limit = args.limit;

let records = state.db.list_generations(None, Some(limit as u32))?;
let records = match args.project.as_deref() {
Some(prefix) => {
let project_id = resolve_project_by_prefix(&state.db, prefix)?;
state
.db
.list_generations_by_project(&project_id, Some(limit as u32))?
}
None => state.db.list_generations(None, Some(limit as u32))?,
};

if json {
let json_output = serde_json::to_string_pretty(&records)
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod files;
mod generation;
mod list;
mod models;
mod project;
mod ps;
mod pull;
mod run;
Expand Down Expand Up @@ -69,6 +70,7 @@ fn run_inner(args: Vec<String>) -> AppResult<()> {
generation::execute(&state, json, command)
}
spec::Commands::List(args) => list::execute(&state, json, args),
spec::Commands::Project { command } => project::execute(&state, json, command),
spec::Commands::Delete(args) => delete::execute(&state, json, args),
spec::Commands::Clear(args) => clear::execute(&state, json, args),
spec::Commands::Ps => ps::execute(&state, json),
Expand Down Expand Up @@ -99,3 +101,26 @@ pub fn human_output(line: &str) {
pub fn human_error(line: &str) {
eprintln!("\x1b[31m✗\x1b[0m Error: {line}");
}

/// Resolve a project reference (ID prefix or exact name) to a project ID.
pub fn resolve_project_by_prefix(
db: &crate::services::db::Database,
prefix: &str,
) -> crate::models::errors::AppResult<String> {
let projects = db.list_projects()?;
let matches: Vec<_> = projects
.iter()
.filter(|p| p.id.starts_with(prefix) || p.name == prefix)
.collect();
match matches.len() {
0 => Err(crate::models::errors::AppError::not_found(
"Project",
format!("No project matches '{prefix}'"),
)),
1 => Ok(matches[0].id.clone()),
_ => Err(crate::models::errors::AppError::validation_failed(format!(
"Ambiguous project prefix '{prefix}' matched {} projects",
matches.len()
))),
}
}
171 changes: 171 additions & 0 deletions src-tauri/src/cli/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use crate::{
cli::{cli_error, human_output, json_output, resolve_project_by_prefix, spec::ProjectCommand},
models::errors::{AppError, AppResult},
};

use super::AppState;

pub fn execute(state: &AppState, json: bool, command: ProjectCommand) -> AppResult<()> {
match command {
ProjectCommand::List => cmd_list(state, json),
ProjectCommand::Create { name } => cmd_create(state, json, &name),
ProjectCommand::Rename { id, name } => cmd_rename(state, json, &id, &name),
ProjectCommand::Delete { id, yes } => cmd_delete(state, json, &id, yes),
ProjectCommand::Assign {
generation,
project,
} => cmd_assign(state, json, &generation, project.as_deref()),
}
}

fn cmd_list(state: &AppState, json: bool) -> AppResult<()> {
let projects = state.db.list_projects()?;

if json {
let output =
serde_json::to_string_pretty(&projects).map_err(|e| cli_error(e.to_string()))?;
json_output(&output);
} else {
if projects.is_empty() {
human_output("No projects.");
return Ok(());
}
println!("{:<12} {:<28} Created", "ID", "Name");
println!("{}", "-".repeat(60));
for project in &projects {
let short_id = &project.id[..8.min(project.id.len())];
let name = if project.name.len() > 26 {
format!("{}…", &project.name[..25])
Comment on lines +37 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Truncating project names with non-ASCII characters crashes the CLI

A project name containing multi-byte characters (e.g., Chinese or emoji) is sliced at a raw byte offset (&project.name[..25] at src-tauri/src/cli/project.rs:38) rather than at a character boundary, so listing projects with long non-ASCII names causes a panic.

Impact: The openloop project list command crashes when any project has a long name containing multi-byte UTF-8 characters.

Byte-index slicing on user-provided UTF-8 strings

project.name.len() returns the byte length, and &project.name[..25] indexes into the string at byte position 25. For multi-byte UTF-8 characters (Chinese characters are 3 bytes, emoji are 4 bytes), byte position 25 can land in the middle of a character. Rust panics with byte index 25 is not a char boundary when this happens.

The app ships with a Chinese (zh-CN) locale (src/locales/zh-CN.json), so users are likely to create projects with Chinese names. A name like "这是一个很长的项目名称用来测试" (30 bytes for 10 characters) would trigger this crash.

The same pattern exists pre-PR for record.prompt[..21] at src-tauri/src/cli/list.rs:42, but that is pre-existing code not introduced by this PR.

Suggested change
let name = if project.name.len() > 26 {
format!("{}…", &project.name[..25])
let name = if project.name.chars().count() > 26 {
format!("{}…", project.name.chars().take(25).collect::<String>())
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} else {
project.name.clone()
};
let created = project
.created_at
.split('T')
.next()
.unwrap_or(&project.created_at);
println!("{:<12} {:<28} {}", short_id, name, created);
}
}
Ok(())
}

fn cmd_create(state: &AppState, json: bool, name: &str) -> AppResult<()> {
let project = state.db.create_project(name)?;
if json {
let output =
serde_json::to_string_pretty(&project).map_err(|e| cli_error(e.to_string()))?;
json_output(&output);
} else {
human_output(&format!(
"Created project '{}' (id: {})",
project.name, project.id
));
}
Ok(())
}

fn cmd_rename(state: &AppState, json: bool, id: &str, name: &str) -> AppResult<()> {
let resolved = resolve_project_by_prefix(&state.db, id)?;
let project = state.db.rename_project(&resolved, name)?;
if json {
let output =
serde_json::to_string_pretty(&project).map_err(|e| cli_error(e.to_string()))?;
json_output(&output);
} else {
human_output(&format!("Renamed project {} to '{}'", resolved, name));
}
Ok(())
}

fn cmd_delete(state: &AppState, json: bool, id: &str, yes: bool) -> AppResult<()> {
let resolved = resolve_project_by_prefix(&state.db, id)?;

if !yes {
use std::io::Write;
let projects = state.db.list_projects()?;
let display_name = projects
.iter()
.find(|p| p.id == resolved)
.map(|p| p.name.as_str())
.unwrap_or(&resolved);
print!(
"Delete project '{}'? Generations will be unassigned, not deleted. [y/N] ",
display_name
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| cli_error(e.to_string()))?;
let trimmed = input.trim();
if !["y", "Y", "yes", "Yes"].contains(&trimmed) {
human_output("Cancelled.");
return Ok(());
}
}

state.db.delete_project(&resolved)?;
if json {
json_output(&format!("{{\"deleted\":\"{}\"}}", resolved));
} else {
human_output(&format!("Deleted project {}", resolved));
}
Ok(())
}

fn cmd_assign(
state: &AppState,
json: bool,
generation: &str,
project: Option<&str>,
) -> AppResult<()> {
let generation_id = resolve_generation_by_prefix(state, generation)?;

let project_id = match project {
Some(value) => Some(resolve_project_by_prefix(&state.db, value)?),
None => None,
};

state
.db
.set_generation_project(&generation_id, project_id.as_deref())?;

if json {
json_output(&format!(
"{{\"generationId\":\"{}\",\"projectId\":{}}}",
generation_id,
project_id
.as_ref()
.map(|id| format!("\"{}\"", id))
.unwrap_or_else(|| "null".to_string())
));
} else {
match project_id {
Some(id) => human_output(&format!(
"Assigned generation {} to project {}",
generation_id, id
)),
None => human_output(&format!(
"Unassigned generation {} from project",
generation_id
)),
}
}
Ok(())
}

fn resolve_generation_by_prefix(state: &AppState, prefix: &str) -> AppResult<String> {
let matches = state.db.find_generation_ids_by_prefix(prefix)?;
match matches.len() {
0 => Err(AppError::not_found(
"Generation record",
format!("No generation record matches '{prefix}'"),
)),
1 => Ok(matches[0].clone()),
_ => Err(AppError::validation_failed(format!(
"Ambiguous generation prefix '{prefix}' matched {} records",
matches.len()
))),
}
}
45 changes: 45 additions & 0 deletions src-tauri/src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,22 @@ pub fn execute(state: &AppState, json: bool, mut args: RunArgs) -> AppResult<()>
}
}

// Assign generations to a project if --project was specified
if let Some(project_ref) = args.project.as_deref() {
let project_id = resolve_project_ref(&state.db, project_ref)?;
for record in &result.records {
state
.db
.set_generation_project(&record.id, Some(&project_id))?;
}
if !json {
human_output(&format!(
"✓ Assigned {} generation(s) to project.",
result.records.len()
));
}
}

// Detach the backend so it keeps running after this CLI command exits
if let Ok(mut backend) = state.lock_backend() {
backend.detach();
Expand Down Expand Up @@ -394,3 +410,32 @@ fn map_model_to_variant(model_name: Option<&str>) -> Option<String> {
_ => None,
}
}

/// Resolve a project reference (ID prefix or exact name) to a project ID.
/// Creates the project if it does not exist (by name match).
fn resolve_project_ref(
db: &crate::services::db::Database,
reference: &str,
) -> crate::models::errors::AppResult<String> {
let projects = db.list_projects()?;
let by_id: Vec<_> = projects
.iter()
.filter(|p| p.id.starts_with(reference))
.collect();
if by_id.len() == 1 {
return Ok(by_id[0].id.clone());
}
if by_id.len() > 1 {
return Err(crate::models::errors::AppError::validation_failed(format!(
"Ambiguous project prefix '{reference}' matched {} projects",
by_id.len()
)));
}
let by_name: Vec<_> = projects.iter().filter(|p| p.name == reference).collect();
if let Some(project) = by_name.first() {
return Ok(project.id.clone());
}
// Create a new project by name
let project = db.create_project(reference)?;
Ok(project.id)
}
47 changes: 47 additions & 0 deletions src-tauri/src/cli/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ pub enum Commands {
},
/// Show generation history
List(ListArgs),
/// Manage projects (named groups of generations)
Project {
#[command(subcommand)]
command: ProjectCommand,
},
/// Delete a generation record
Delete(DeleteArgs),
/// Clear all generation history
Expand Down Expand Up @@ -141,6 +146,10 @@ pub struct RunArgs {
/// Replay a previous generation by ID
#[arg(long)]
pub from_history: Option<String>,

/// Assign the generation to a project (by name or ID prefix)
#[arg(long)]
pub project: Option<String>,
}

#[derive(Args)]
Expand Down Expand Up @@ -300,6 +309,40 @@ pub enum GenerationCommand {
},
}

#[derive(Subcommand)]
pub enum ProjectCommand {
/// List all projects
List,
/// Create a new project
Create {
/// Project name
name: String,
},
/// Rename an existing project
Rename {
/// Project ID prefix
id: String,
/// New project name
name: String,
},
/// Delete a project (generations are unassigned, not deleted)
Delete {
/// Project ID prefix
id: String,
/// Skip confirmation
#[arg(long)]
yes: bool,
},
/// Assign a generation to a project
Assign {
/// Generation ID prefix
generation: String,
/// Project ID prefix (omit to unassign)
#[arg(long)]
project: Option<String>,
},
}

// ---------------------------------------------------------------------------
// Leaf commands
// ---------------------------------------------------------------------------
Expand All @@ -309,6 +352,10 @@ pub struct ListArgs {
/// Number of records to show
#[arg(long, default_value = "20")]
pub limit: usize,

/// Filter by project (by name or ID prefix)
#[arg(long)]
pub project: Option<String>,
}

#[derive(Args)]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod history;
pub mod logs;
pub mod models;
pub mod network;
pub mod projects;
pub mod provisioner;
pub mod settings;
pub mod support;
Expand Down
Loading
Loading