wsb 0.222.4636

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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'"
        );
    }
}