1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
use crate::prelude::*;
use tokio::sync::broadcast;
use crate::logging::{LogLine, LogRingBuffer};
use crate::persist::global::{DatabaseConfig, DatabaseService, WsbRepos};
use crate::state::read_major_version_from_file;
use crate::workspace;
#[derive(Clone)]
pub struct AppContext {
pub db: Arc<WsbRepos>,
pub db_service: Arc<DatabaseService>,
/// Most senior git repository root — DB lives at `workspace_root/.wsb/wsb.db`.
pub workspace_root: PathBuf,
/// Nearest ancestor containing `.wsb/state.json` — project-level config lives here.
pub project_root: PathBuf,
}
impl AppContext {
/// Bootstrap from an initialized `project_root` (a directory that already
/// contains `.wsb/state.json`).
///
/// The DB is placed at the *super root* (most senior git repo), which equals
/// `project_root` in the common (non-submodule) case.
///
/// Seeds `major_version` from `version.txt` at `project_root` into the DB
/// project row if no row exists yet or if the row still has the default of 0.
///
/// Call this for every command except `wsb init`. For `wsb init` use
/// [`AppContext::bootstrap_for_init`] instead, which does not require an
/// initialized workspace directory.
pub fn bootstrap(project_root: &Path) -> Result<Self> {
let db_root = workspace::find_super_root_or_project(project_root);
let db_path = db_root.join(".wsb").join("wsb.db");
let config = DatabaseConfig::new(&db_path);
let service = DatabaseService::new(&config)?;
let service = Arc::new(service);
let repos = Arc::new(WsbRepos::new(&service));
let ctx = Self {
db: repos,
db_service: service,
workspace_root: db_root,
project_root: project_root.to_path_buf(),
};
if let Some(major) = read_major_version_from_file(project_root) {
ctx.seed_major_version(major)?;
}
Ok(ctx)
}
/// Bootstrap for `wsb init`, where no `.wsb/state.json` exists yet.
///
/// `cwd` is the directory where `wsb init` was invoked. The DB is placed at
/// the super root derived from `cwd` (walking up through `.git` file links).
/// `project_root` is set to `cwd` — it will become a valid project root once
/// `WorkspaceState::initialize` creates `.wsb/state.json` there.
///
/// No version seeding is attempted here because `version.txt` may not be
/// readable before the workspace is created. Seeding happens in
/// `setup_new_project` after `WorkspaceState::initialize` completes.
pub fn bootstrap_for_init(cwd: &Path) -> Result<Self> {
let db_root = workspace::find_super_root_or_project(cwd);
let db_path = db_root.join(".wsb").join("wsb.db");
let config = DatabaseConfig::new(&db_path);
let service = DatabaseService::new(&config)?;
let service = Arc::new(service);
let repos = Arc::new(WsbRepos::new(&service));
Ok(Self {
db: repos,
db_service: service,
workspace_root: db_root,
project_root: cwd.to_path_buf(),
})
}
/// Update the project major_version in the DB if no row exists yet
/// (i.e. first-run seeding). Called from bootstrap so that the very first
/// StateConfig::load will find the correct value already persisted.
pub fn seed_major_version(&self, major: u32) -> Result<()> {
let conn = self.db_service.get_connection()
.context("Failed to get DB connection for major version seeding")?;
let existing: Option<i64> = conn
.query_row(
"SELECT major_version FROM projects LIMIT 1",
[],
|row| row.get(0),
)
.ok();
if existing.is_none() {
// No project row yet — the lazy init will use version.txt.
// But we can proactively insert one now with the correct major.
conn.execute(
"INSERT OR IGNORE INTO projects (id, code, name, description, status, major_version, version_file, auto_detect_project_files, project_files) \
VALUES ('P001', 'P001', 'Default Project', 'Auto-created project', 'active', ?1, 'version.txt', 1, '[]')",
rusqlite::params![major as i32],
)?;
} else {
// Project row exists — update major_version if it's still 0 (default).
let current = existing.unwrap() as u32;
if current == 0 && major > 0 {
conn.execute(
"UPDATE projects SET major_version = ?1 WHERE id = (SELECT id FROM projects LIMIT 1)",
rusqlite::params![major as i32],
)?;
}
}
Ok(())
}
}
#[derive(Clone)]
pub struct AppState {
pub ctx: AppContext,
pub debug: bool,
pub port: u16,
pub log_tx: broadcast::Sender<LogLine>,
pub log_ring: LogRingBuffer,
}
impl AppState {
pub fn new(
ctx: AppContext,
debug: bool,
port: u16,
log_tx: broadcast::Sender<LogLine>,
log_ring: LogRingBuffer,
) -> Self {
Self {
ctx,
debug,
port,
log_tx,
log_ring,
}
}
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct SyncProgress {
pub phase: SyncPhase,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_project: Option<String>,
pub sessions_total: usize,
pub sessions_done: usize,
pub messages_indexed: usize,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncPhase {
Idle,
Discovering,
Syncing,
Done,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_git_repo(path: &std::path::Path) {
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(path)
.status()
.expect("git init failed");
}
#[test]
fn bootstrap_for_init_sets_project_root_to_cwd_not_super_root() {
// Simulates: a submodule directory with no .wsb/state.json yet.
// The super root is the parent git repo. bootstrap_for_init must:
// - place the DB at the super root (.wsb/wsb.db lives there)
// - set project_root to cwd (the uninitialized submodule dir), NOT the super root
let tmp = TempDir::new().unwrap();
let super_dir = tmp.path().join("super");
let sub_dir = super_dir.join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
// Two real git repos: super contains sub.
init_git_repo(&super_dir);
init_git_repo(&sub_dir);
let ctx = AppContext::bootstrap_for_init(&sub_dir).unwrap();
// DB must land at super root, not inside the submodule.
assert_eq!(ctx.workspace_root, super_dir,
"DB root must be the super root, not the submodule dir");
assert!(ctx.workspace_root.join(".wsb").join("wsb.db").exists(),
"wsb.db must be created at super root/.wsb/wsb.db");
// project_root must be exactly cwd — the submodule dir — even though
// .wsb/state.json doesn't exist there yet.
assert_eq!(ctx.project_root, sub_dir,
"project_root must be cwd (the uninitialised submodule dir), not the super root");
// No wsb.db should be created inside the submodule itself.
assert!(!sub_dir.join(".wsb").join("wsb.db").exists(),
"wsb.db must NOT be created inside the submodule directory");
}
#[test]
fn bootstrap_for_init_does_not_seed_major_version_before_init() {
// bootstrap_for_init must not attempt version seeding — version.txt may
// not exist, and .wsb/ doesn't exist yet. Seeding happens after
// WorkspaceState::initialize in setup_new_project.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("project");
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
// No version.txt, no .wsb/ — bootstrap_for_init must succeed regardless.
let ctx = AppContext::bootstrap_for_init(&dir).unwrap();
// DB was created but has no project row yet (seeding not attempted).
let conn = ctx.db_service.get_connection().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM projects", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0,
"bootstrap_for_init must not insert a project row — that happens after wsb init creates .wsb/");
}
#[test]
fn bootstrap_seeds_major_version_from_version_txt() {
// bootstrap (the normal path, not for init) must seed major_version from
// version.txt when a project row doesn't exist yet.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("project");
std::fs::create_dir_all(dir.join(".wsb")).unwrap();
init_git_repo(&dir);
std::fs::write(dir.join("version.txt"), "7.3.100\n").unwrap();
std::fs::write(
dir.join(".wsb").join("state.json"),
r#"{"version":1,"project_root":"/tmp","project_name":"test","tools":{}}"#,
).unwrap();
let ctx = AppContext::bootstrap(&dir).unwrap();
let conn = ctx.db_service.get_connection().unwrap();
let major: i64 = conn
.query_row(
"SELECT major_version FROM projects LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(major, 7,
"bootstrap must seed major_version=7 from version.txt '7.3.100'");
}
}