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
54 changes: 54 additions & 0 deletions docs/standards/codecora-data-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# CodeCora Data Directory Standard

All CodeCora products store their runtime data under `$HOME/.codecora/{product}/`.

## Layout

```
~/.codecora/
├── cora-code/ # cora-code runtime data
│ ├── graph.db # knowledge graph (Phase 2)
│ └── index/ # embedding cache, index files
├── nginjen/ # nginjen runtime data
│ ├── config.toml # runtime config
│ └── engines/ # cached browser engines
├── trapfall/ # trapfall runtime data
│ └── trapfall.db # error capture database
└── uteke/ # uteke runtime data (future migration from ~/.uteke/)
└── uteke.db # semantic memory database
```

## Rules

1. **Path**: `$HOME/.codecora/{product}/` — always resolve via `dirs::home_dir()`
2. **Product name**: lowercase, kebab-case (`cora-code`, `nginjen`, `trapfall`, `uteke`)
3. **Auto-create**: `create_dir_all()` on first access
4. **SQLite files**: `{product}.db` inside the product directory
5. **Cross-product read**: any CodeCora product MAY read another product's data
(e.g., cora-code reading uteke memories, trapfall reading cora-code graph)
6. **No config here**: config files go elsewhere (env vars, CLI flags, project-local `.cora/`)
7. **Env override**: `{PRODUCT}_DATA_DIR` overrides the default path

## Reference Implementation (Rust)

```rust
use std::path::PathBuf;

pub fn data_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CORA_CODE_DATA_DIR") {
return PathBuf::from(dir);
}
let home = dirs::home_dir()
.expect("home directory not found");
home.join(".codecora").join("cora-code")
}
```

## Adoption Status

| Product | Status | Notes |
|---------|--------|-------|
| nginjen | ✅ Adopted | `engine.rs` uses `~/.codecora/nginjen/engines`, `config.rs` uses XDG dirs (inconsistency — align to this standard) |
| trapfall | ✅ Adopted | `config.rs` uses `$HOME/.codecora/trapfall/` |
| cora-code | 🔜 Phase 2 | Will store `graph.db` here |
| uteke | ⏸️ Deferred | Currently `~/.uteke/` — established path, low priority migration |
96 changes: 96 additions & 0 deletions src/data_dir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! CodeCora data directory resolution.
//!
//! All CodeCora products store runtime data under `$HOME/.codecora/{product}/`.
//! This module provides the shared resolution logic.

use std::path::PathBuf;

/// Environment variable to override the CodeCora data root.
/// When set, all products use this as the parent directory instead of `$HOME/.codecora/`.
pub const CODECORA_HOME_ENV: &str = "CODECORA_HOME";

/// Returns the CodeCora data directory: `$HOME/.codecora/` (or `CODECORA_HOME` override).
///
/// ```text
/// CODECORA_HOME=/custom → /custom
/// (not set) → $HOME/.codecora/
/// ```
pub fn codecora_home() -> PathBuf {
if let Ok(home) = std::env::var(CODECORA_HOME_ENV) {
PathBuf::from(home)
} else {
dirs::home_dir()
.expect("Cannot determine home directory. Set CODECORA_HOME or HOME.")
.join(".codecora")
}
}

/// Returns the data directory for a specific CodeCora product.
///
/// ```text
/// product_data_dir("cora-code") → $HOME/.codecora/cora-code/
/// ```
pub fn product_data_dir(product: &str) -> PathBuf {
codecora_home().join(product)
}

/// Returns the cora-code data directory: `$HOME/.codecora/cora-code/`.
pub fn cora_data_dir() -> PathBuf {
product_data_dir("cora-code")
}

/// Returns the path to the global graph database.
pub fn graph_db_path() -> PathBuf {
cora_data_dir().join("graph.db")
}

/// Ensure the cora-code data directory exists.
pub fn ensure_data_dir() -> anyhow::Result<PathBuf> {
let dir = cora_data_dir();
std::fs::create_dir_all(&dir)?;
Ok(dir)
}

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

#[test]
fn test_codecora_home_returns_path() {
let path = codecora_home();
assert!(path.ends_with(".codecora"));
}

#[test]
fn test_product_data_dir() {
let path = product_data_dir("cora-code");
assert!(path.ends_with(".codecora/cora-code"));
}

#[test]
fn test_graph_db_path() {
let path = graph_db_path();
assert!(path.ends_with(".codecora/cora-code/graph.db"));
}

#[test]
fn test_cora_data_dir() {
let path = cora_data_dir();
assert!(path.ends_with(".codecora/cora-code"));
// Should not have trailing slash
let s = path.to_string_lossy();
assert!(!s.ends_with('/'));
}

#[test]
fn test_env_override() {
unsafe {
std::env::set_var(CODECORA_HOME_ENV, "/tmp/test-codecora");
}
let path = codecora_home();
assert_eq!(path, PathBuf::from("/tmp/test-codecora"));
unsafe {
std::env::remove_var(CODECORA_HOME_ENV);
}
}
}
99 changes: 61 additions & 38 deletions src/index/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,37 +22,42 @@ pub struct CallEdge {
pub line: u32,
}

/// Store call edges in the database.
/// Store call edges in the database, scoped to a project.
#[allow(dead_code)]
pub fn store_edges(conn: &Connection, edges: &[CallEdge]) -> anyhow::Result<usize> {
pub fn store_edges(
conn: &Connection,
edges: &[CallEdge],
project_id: i64,
) -> anyhow::Result<usize> {
let tx = conn.unchecked_transaction()?;
let mut count = 0;
for edge in edges {
tx.execute(
"INSERT INTO call_graph (caller, callee, file, line) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![edge.caller, edge.callee, edge.file, edge.line as i64],
"INSERT INTO call_graph (caller, callee, file, line, project_id) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![edge.caller, edge.callee, edge.file, edge.line as i64, project_id],
)?;
count += 1;
}
tx.commit()?;
Ok(count)
}

/// Clear call graph edges for a specific file (before re-indexing).
/// Clear call graph edges for a specific file (before re-indexing), scoped to project.
#[allow(dead_code)]
pub fn clear_edges_for_file(conn: &Connection, file: &str) -> anyhow::Result<()> {
pub fn clear_edges_for_file(conn: &Connection, file: &str, project_id: i64) -> anyhow::Result<()> {
conn.execute(
"DELETE FROM call_graph WHERE file = ?1",
rusqlite::params![file],
"DELETE FROM call_graph WHERE file = ?1 AND project_id = ?2",
rusqlite::params![file, project_id],
)?;
Ok(())
}

/// Find all callers of a symbol (who calls this?).
/// Find all callers of a symbol (who calls this?), scoped to a project.
///
/// Returns symbols that call the given name, grouped by file.
pub fn find_callers(
conn: &Connection,
project_id: i64,
symbol_name: &str,
limit: usize,
) -> anyhow::Result<Vec<CallerResult>> {
Expand All @@ -61,27 +66,31 @@ pub fn find_callers(
let mut stmt = conn.prepare(
"SELECT DISTINCT cg.caller, cg.file, cg.line
FROM call_graph cg
WHERE cg.callee LIKE ?1
LIMIT ?2",
WHERE cg.callee LIKE ?1 AND cg.project_id = ?2
LIMIT ?3",
)?;

let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| {
Ok(CallerResult {
caller: row.get(0)?,
file: row.get(1)?,
line: row.get::<_, i64>(2)? as u32,
})
})?;
let rows = stmt.query_map(
rusqlite::params![pattern, project_id, limit as i64],
|row| {
Ok(CallerResult {
caller: row.get(0)?,
file: row.get(1)?,
line: row.get::<_, i64>(2)? as u32,
})
},
)?;

Ok(rows.filter_map(|r| r.ok()).collect())
}

/// Find all callees of a symbol (what does this call?).
/// Find all callees of a symbol (what does this call?), scoped to a project.
///
/// Returns symbols that are called by the given name.
#[allow(dead_code)]
pub fn find_callees(
conn: &Connection,
project_id: i64,
symbol_name: &str,
limit: usize,
) -> anyhow::Result<Vec<CalleeResult>> {
Expand All @@ -90,26 +99,30 @@ pub fn find_callees(
let mut stmt = conn.prepare(
"SELECT DISTINCT cg.callee, cg.file, cg.line
FROM call_graph cg
WHERE cg.caller LIKE ?1
LIMIT ?2",
WHERE cg.caller LIKE ?1 AND cg.project_id = ?2
LIMIT ?3",
)?;

let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| {
Ok(CalleeResult {
callee: row.get(0)?,
file: row.get(1)?,
line: row.get::<_, i64>(2)? as u32,
})
})?;
let rows = stmt.query_map(
rusqlite::params![pattern, project_id, limit as i64],
|row| {
Ok(CalleeResult {
callee: row.get(0)?,
file: row.get(1)?,
line: row.get::<_, i64>(2)? as u32,
})
},
)?;

Ok(rows.filter_map(|r| r.ok()).collect())
}

/// Impact analysis: what breaks if a symbol changes?
/// Impact analysis: what breaks if a symbol changes?, scoped to a project.
///
/// Uses reverse traversal: find all callers recursively up to `depth`.
pub fn impact_analysis(
conn: &Connection,
project_id: i64,
symbol_name: &str,
depth: u32,
) -> anyhow::Result<Vec<ImpactNode>> {
Expand All @@ -126,7 +139,7 @@ pub fn impact_analysis(
continue;
}

let callers = find_callers(conn, sym, 100)?;
let callers = find_callers(conn, project_id, sym, 100)?;
for caller in callers {
let node = ImpactNode {
symbol: caller.caller.clone(),
Expand Down Expand Up @@ -195,9 +208,15 @@ mod tests {
conn
}

/// Create a test project and return its project_id.
fn test_project(conn: &Connection) -> i64 {
super::super::schema::get_or_create_project(conn, "/tmp/test-project").unwrap()
}

#[test]
fn test_store_and_find_callers() {
let conn = mem_conn();
let project_id = test_project(&conn);

let edges = vec![
CallEdge {
Expand All @@ -213,9 +232,9 @@ mod tests {
line: 25,
},
];
store_edges(&conn, &edges).unwrap();
store_edges(&conn, &edges, project_id).unwrap();

let callers = find_callers(&conn, "authenticate", 10).unwrap();
let callers = find_callers(&conn, project_id, "authenticate", 10).unwrap();
assert_eq!(callers.len(), 2);
let names: Vec<&str> = callers.iter().map(|c| c.caller.as_str()).collect();
assert!(names.contains(&"main"));
Expand All @@ -225,6 +244,7 @@ mod tests {
#[test]
fn test_find_callees() {
let conn = mem_conn();
let project_id = test_project(&conn);

let edges = vec![
CallEdge {
Expand All @@ -240,15 +260,16 @@ mod tests {
line: 10,
},
];
store_edges(&conn, &edges).unwrap();
store_edges(&conn, &edges, project_id).unwrap();

let callees = find_callees(&conn, "main", 10).unwrap();
let callees = find_callees(&conn, project_id, "main", 10).unwrap();
assert_eq!(callees.len(), 2);
}

#[test]
fn test_clear_edges_for_file() {
let conn = mem_conn();
let project_id = test_project(&conn);
store_edges(
&conn,
&[CallEdge {
Expand All @@ -257,17 +278,19 @@ mod tests {
file: "test.rs".to_string(),
line: 1,
}],
project_id,
)
.unwrap();

clear_edges_for_file(&conn, "test.rs").unwrap();
let callers = find_callers(&conn, "b", 10).unwrap();
clear_edges_for_file(&conn, "test.rs", project_id).unwrap();
let callers = find_callers(&conn, project_id, "b", 10).unwrap();
assert!(callers.is_empty());
}

#[test]
fn test_impact_analysis_depth() {
let conn = mem_conn();
let project_id = test_project(&conn);
// a → b → c
// If c changes, impact should find b (depth 1) and a (depth 2)
let edges = vec![
Expand All @@ -284,9 +307,9 @@ mod tests {
line: 1,
},
];
store_edges(&conn, &edges).unwrap();
store_edges(&conn, &edges, project_id).unwrap();

let impact = impact_analysis(&conn, "c", 3).unwrap();
let impact = impact_analysis(&conn, project_id, "c", 3).unwrap();
// Should find b at depth 1, a at depth 2
assert!(impact.iter().any(|n| n.symbol == "b" && n.depth == 1));
assert!(impact.iter().any(|n| n.symbol == "a" && n.depth == 2));
Expand Down
Loading
Loading