use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use colored::Colorize;
use std::env;
use std::fs;
use std::io::IsTerminal;
use std::io::Write;
use std::process::exit;
use std::time::Instant;
use wsb::app::AppContext;
use wsb::fleet::config::GitModules;
use wsb::fleet::error::SuperGitError;
use wsb::fleet::git::{SubmoduleOps, SubmoduleTree};
use wsb::logging::{
log_operation_complete, log_operation_error, log_operation_start, log_version_info,
setup_logging,
};
use wsb::prelude::*;
use wsb::state::discard::owns_templates;
use wsb::state::{
detect_project_files, is_git_repository_in, update_version_file_in_repo, RunCache, StateConfig,
TemplateManager, VersionInfo, WstemplateEngine,
};
use wsb::workspace;
use wsb::workspace_state::{WorkspaceState, WstemplateEntry};
#[derive(Parser, Debug)]
#[command(name = "wsb")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Workspace - All-in-one development tool suite")]
#[command(
after_help = "When no subcommand is provided, `wsb` defaults to `serve`.\nRun `wsb completions` to install shell completions."
)]
struct Args {
#[arg(long, global = true)]
jsonl: bool,
#[arg(long, global = true)]
no_daemon: bool,
#[arg(long, global = true)]
ignore_workspace_lock: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Refactor {
#[command(flatten)]
args: wsb::refactor::Args,
},
Status {
#[arg(short, long)]
verbose: bool,
},
Discard {
#[arg(long)]
untracked: bool,
#[arg(long)]
yes: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Checkout {
branch: String,
#[arg(short, long)]
create: bool,
#[arg(long)]
ignore_missing: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Pull {
#[arg(short, long)]
rebase: bool,
#[arg(long)]
autostash: bool,
#[arg(long, conflicts_with = "autostash")]
overwrite_local: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Push {
#[arg(short, long)]
force_with_lease: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Create {
branch: String,
#[arg(short, long)]
from: Option<String>,
#[arg(short, long)]
push: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Commit {
#[arg(short, long)]
message: String,
#[arg(short, long)]
push: bool,
#[arg(short = 'a', long)]
stage_all: bool,
#[arg(short = 'x', long = "exclude", value_name = "ALIAS")]
exclude: Vec<String>,
#[arg(short = 'i', long = "include", value_name = "ALIAS")]
include: Vec<String>,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Sync {
#[arg(short, long)]
init: bool,
#[arg(short, long)]
recursive: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Delete {
branch: String,
#[arg(short, long)]
remote: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Fetch {
#[arg(short, long)]
all: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Branch {
#[arg(short, long)]
all: bool,
#[arg(short, long)]
verbose: bool,
},
SubmoduleInit {
#[arg(short, long)]
recursive: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Diff {
#[arg(long, conflicts_with = "since")]
base: Option<String>,
#[arg(long, conflicts_with = "base")]
since: Option<String>,
#[arg(short, long, default_value = "diffs")]
output: String,
#[arg(short, long)]
staged: bool,
#[arg(long)]
clean: bool,
#[arg(short, long)]
verbose: bool,
},
Stage {
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
MergeBack {
#[arg(short, long, default_value = "Pre-merge commit")]
message: String,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Template {
#[command(subcommand)]
action: TemplateAction,
},
Completions {
#[arg(long)]
shell: Option<Shell>,
},
Local {
#[command(subcommand)]
action: LocalAction,
},
Checkpoint {
#[arg(short, long)]
message: String,
#[arg(long)]
no_push: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Update {
#[arg(long)]
no_git: bool,
#[arg(long)]
git_add: bool,
#[arg(long)]
version_suffix: Option<String>,
#[arg(long, conflicts_with = "version_suffix")]
tag: Option<String>,
#[arg(long, short)]
quiet: bool,
#[arg(long)]
recursive: bool,
#[arg(long)]
verify: bool,
#[arg(long = "discard")]
discard: bool,
#[arg(long = "dry-run", requires = "discard")]
dry_run: bool,
},
Scrap {
paths: Vec<std::path::PathBuf>,
#[command(subcommand)]
command: Option<ScrapCommands>,
},
Unscrap {
name: Option<String>,
#[arg(short, long)]
force: bool,
#[arg(short = 't', long)]
to: Option<std::path::PathBuf>,
},
Ldiff {
#[arg(default_value = "░")]
substitute_char: String,
},
Compact {
input: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
token_mode: bool,
},
AuditPath,
Ignore {
line: String,
},
LastHash {
file: String,
},
Code {
#[command(subcommand)]
action: Option<CodeAction>,
},
Workspace {
#[command(subcommand)]
action: WorkspaceAction,
},
#[command(disable_help_flag = true)]
Peer {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Plugin {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Worktree {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Github {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Test {
#[arg(long)]
dry_run: bool,
#[arg(long)]
install: bool,
#[arg(last = true)]
args: Vec<String>,
},
Sample {
#[arg(long)]
project: bool,
#[arg(long)]
data: bool,
#[arg(long)]
force: bool,
#[arg(short, long, default_value = "sample-project")]
output: String,
},
Serve {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
listen: Option<std::net::IpAddr>,
#[arg(long)]
debug: bool,
#[arg(short, long)]
workspace: Option<String>,
#[arg(long)]
parent_pid: Option<u32>,
},
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
Init {
first_task: Option<String>,
#[arg(long)]
name: Option<String>,
},
Check {
#[arg(long)]
no_network: bool,
#[arg(long, short)]
quiet: bool,
},
Relationship {
#[command(subcommand)]
action: RelationshipAction,
},
Note {
#[command(subcommand)]
action: NoteAction,
},
Database {
#[command(subcommand)]
action: DatabaseAction,
},
Continuity {
#[command(subcommand)]
action: ContinuityAction,
},
Version {
#[command(subcommand)]
action: VersionAction,
},
Wstemplate {
#[command(subcommand)]
action: WstemplateAction,
},
Each {
#[arg(short, long)]
file: Option<std::path::PathBuf>,
#[arg(long, short)]
quiet: bool,
},
Catalog {
#[command(subcommand)]
action: CatalogAction,
},
}
#[derive(Subcommand, Debug)]
enum WorkspaceAction {
RelocateState,
}
#[derive(Subcommand, Debug)]
enum DaemonAction {
Status,
Stop,
Restart {
#[arg(short, long)]
port: Option<u16>,
},
Log {
#[arg(short = 'n', long, default_value = "40")]
lines: usize,
},
Install {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
peer_port: Option<u16>,
},
Uninstall,
Enable,
Disable,
}
#[derive(Subcommand, Debug)]
enum CatalogAction {
List {
#[arg(long)]
repo: Option<String>,
},
Scan {
#[arg(long, short)]
quiet: bool,
},
Report,
Parity {
family: String,
#[arg(long, short)]
output: Option<PathBuf>,
#[arg(long)]
git_add: bool,
},
Free {
#[arg(long, default_value_t = 1)]
from: i64,
#[arg(long, default_value_t = 5)]
count: usize,
},
Export {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
git_add: bool,
},
}
#[derive(Subcommand, Debug)]
enum RelationshipAction {
Link {
from_entity: String,
#[arg(short, long)]
from_type: String,
to_entity: String,
#[arg(short, long)]
to_type: String,
#[arg(short, long, default_value = "depends_on")]
relationship_type: String,
#[arg(short, long)]
description: Option<String>,
},
List {
entity_id: String,
#[arg(short, long)]
entity_type: String,
#[arg(short, long)]
relationship_type: Option<String>,
#[arg(long)]
include_resolved: bool,
},
Unlink {
dependency_id: String,
#[arg(short, long)]
force: bool,
},
Resolve {
dependency_id: String,
#[arg(short, long)]
description: Option<String>,
},
Stats {
#[arg(short, long)]
detailed: bool,
#[arg(short, long, default_value = "human")]
format: String,
},
}
#[derive(Subcommand, Debug)]
enum NoteAction {
Add {
#[arg(short, long)]
entity_type: String,
#[arg(short = 'i', long)]
entity_id: String,
title: String,
content: String,
#[arg(short = 't', long, default_value = "observation")]
note_type: String,
#[arg(long)]
tags: Option<String>,
},
AddProject {
title: String,
content: String,
#[arg(short = 't', long, default_value = "architecture")]
note_type: String,
#[arg(long)]
tags: Option<String>,
},
List {
#[arg(short, long)]
entity_type: Option<String>,
#[arg(short = 'i', long)]
entity_id: Option<String>,
#[arg(short = 't', long)]
note_type: Option<String>,
#[arg(long)]
project_wide: bool,
#[arg(long)]
pinned: bool,
},
Search {
query: String,
#[arg(short = 't', long)]
note_type: Option<String>,
#[arg(short, long, default_value = "human")]
format: String,
},
Update {
note_id: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
content: Option<String>,
#[arg(long)]
tags: Option<String>,
},
Delete {
note_id: String,
#[arg(short, long)]
force: bool,
},
Pin {
note_id: String,
},
Link {
source_note_id: String,
target_id: String,
#[arg(short = 't', long, default_value = "entity")]
target_type: String,
#[arg(long)]
entity_type: Option<String>,
#[arg(short = 'l', long, default_value = "reference")]
link_type: String,
},
Unlink {
link_id: String,
#[arg(short, long)]
force: bool,
},
ListLinks {
id: String,
#[arg(long)]
incoming: bool,
#[arg(long)]
outgoing: bool,
#[arg(short, long, default_value = "human")]
format: String,
},
}
#[derive(Subcommand, Debug)]
enum LocalAction {
On {
#[arg(long, short)]
quiet: bool,
#[arg(long)]
git_add: bool,
#[arg(long)]
verify: bool,
},
Off {
#[arg(short = 'n', long)]
dry_run: bool,
},
Status {
#[arg(long)]
files: bool,
},
}
#[derive(Subcommand, Debug)]
enum TemplateAction {
Add {
name: String,
#[arg(long)]
template: String,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long)]
description: Option<String>,
},
List,
Show {
name: String,
},
Enable {
name: String,
#[arg(long)]
disable: bool,
},
Remove {
name: String,
},
Render,
GenerateDocs {
#[arg(default_value = "all")]
doc_type: String,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long)]
force: bool,
},
InitDocs {
#[arg(short, long)]
force: bool,
},
}
#[derive(Subcommand, Debug)]
enum DatabaseAction {
Backup {
#[arg(short, long)]
backup_dir: Option<String>,
#[arg(short, long)]
compress: bool,
#[arg(short, long, default_value = "10")]
max_backups: usize,
},
List {
#[arg(short, long)]
backup_dir: Option<String>,
#[arg(short, long, default_value = "table")]
format: String,
},
Restore {
backup_id: String,
#[arg(short, long)]
target: Option<String>,
#[arg(short, long)]
force: bool,
},
Cleanup {
#[arg(short, long)]
backup_dir: Option<String>,
#[arg(short, long, default_value = "10")]
max_backups: usize,
#[arg(short = 'n', long)]
dry_run: bool,
},
Health {
#[arg(short, long)]
performance: bool,
},
}
#[derive(Subcommand, Debug)]
enum CodeAction {
Tree {
#[arg(short, long, default_value = "3")]
depth: usize,
#[arg(long)]
hidden: bool,
#[arg(long)]
sizes: bool,
#[arg(long)]
extensions: Option<String>,
#[arg(long)]
no_ignore: bool,
},
Search {
pattern: String,
#[arg(short, long)]
files: Vec<PathBuf>,
#[arg(short, long)]
language: Option<String>,
#[arg(short, long, default_value = "3")]
context: usize,
#[arg(short, long, default_value = "1000")]
max_matches: usize,
#[arg(long, default_value = "human")]
format: String,
},
Transform {
pattern: String,
replacement: String,
#[arg(short, long)]
files: Vec<PathBuf>,
#[arg(short, long)]
language: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_backup: bool,
#[arg(long, default_value = "100")]
max_changes: usize,
},
Patterns {
language: String,
#[arg(long, default_value = "search")]
category: String,
},
Analyze {
#[arg(short, long)]
files: Vec<PathBuf>,
#[arg(short, long)]
language: Option<String>,
#[arg(long, default_value = "structure")]
analysis_type: String,
#[arg(long, default_value = "human")]
format: String,
},
}
#[derive(Subcommand, Debug)]
enum ContinuityAction {
Save {
session_id: String,
#[arg(short, long)]
focus: String,
#[arg(short, long)]
notes: Option<String>,
},
Load {
session_id: String,
#[arg(short, long, default_value = "summary")]
format: String,
},
Transfer {
from_session: String,
to_session: String,
#[arg(short, long)]
force: bool,
},
List {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long, default_value = "table")]
format: String,
},
Snapshot {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long, default_value = "summary")]
format: String,
},
}
#[derive(Subcommand, Debug)]
enum VersionAction {
Show {
#[arg(long)]
verbose: bool,
#[arg(short, long, default_value = "human")]
format: String,
},
Major {
version: u32,
},
Tag {
#[arg(long, default_value = "v")]
prefix: String,
#[arg(short, long)]
message: Option<String>,
},
Info {
#[arg(long)]
include_history: bool,
},
}
#[derive(Subcommand, Debug)]
enum WstemplateAction {
Add {
path: PathBuf,
#[arg(long)]
alias: Option<String>,
},
Remove {
alias: String,
},
ListEntries,
List,
Render,
Vars {
alias: Option<String>,
#[arg(long)]
remote: bool,
},
}
#[derive(Subcommand, Debug)]
enum ScrapCommands {
#[command(alias = "ls")]
List {
#[arg(short, long, default_value = "date")]
sort: String,
},
Clean {
#[arg(short, long, default_value = "30")]
days: u64,
#[arg(short = 'n', long)]
dry_run: bool,
},
Purge {
#[arg(short, long)]
force: bool,
},
Find {
pattern: String,
#[arg(short, long)]
content: bool,
},
Archive {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
#[arg(short, long)]
remove: bool,
},
}
const PRE_COMMIT_HOOK: &str = "#!/bin/sh\n\"${WSB_SELF:-wsb}\" update --quiet --git-add\n";
const PRE_COMMIT_MARK: &str = "update --quiet --git-add";
fn pre_commit_command() -> &'static str {
PRE_COMMIT_HOOK
.lines()
.last()
.expect("the hook has a command")
}
fn is_wsb_hook(content: &str) -> bool {
let mut commands = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'));
commands.next().is_some_and(is_wsb_update) && commands.next().is_none()
}
fn is_wsb_update(command: &str) -> bool {
let mut words = command.split_whitespace();
let Some(program) = words.next() else {
return false;
};
let program = program.trim_matches('"');
let names_wsb = program == "${WSB_SELF:-wsb}"
|| std::path::Path::new(program).file_name().is_some_and(|name| name == "wsb");
names_wsb && words.next() == Some("update") && words.any(|word| word == "--git-add")
}
fn pre_commit_hook_problem(content: &str) -> Option<String> {
let own = is_wsb_hook(content);
let current = content.contains(PRE_COMMIT_MARK) && content.contains(wsb::git_backend::SELF_ENV);
if own && !current {
Some("is an older version of wsb's own hook — run 'wsb init' to reinstall it".to_string())
} else if !content.contains(PRE_COMMIT_MARK) {
Some(format!(
"does not invoke '{PRE_COMMIT_MARK}' — it is a hook of your own; add this line to \
it: {}",
pre_commit_command()
))
} else if !content.contains(wsb::git_backend::SELF_ENV) {
Some(if own {
"runs whichever wsb is on PATH rather than the one driving the commit — run \
'wsb init' to reinstall it"
.to_string()
} else {
format!(
"runs whichever wsb is on PATH rather than the one driving the commit — change \
its wsb line to: {}",
pre_commit_command()
)
})
} else {
None
}
}
const PROGRAM_STACK_BYTES: usize = 8 * 1024 * 1024;
fn main() {
let program = std::thread::Builder::new()
.name("main".to_string())
.stack_size(PROGRAM_STACK_BYTES)
.spawn(program);
match program {
Ok(running) => {
if running.join().is_err() {
exit(101);
}
}
Err(e) => {
wsb::output::failed(
&format!("could not start wsb on its own {PROGRAM_STACK_BYTES}-byte stack: {e}"),
"startup",
);
exit(wsb::output::finish(1));
}
}
}
fn program() {
if let Ok(own) = std::env::current_exe() {
wsb::git_backend::announce_running_wsb(own);
}
let mut argv: Vec<String> = std::env::args().collect();
let jsonl = wsb::output::take_jsonl_flag(&mut argv);
if take_no_daemon_flag(&mut argv) {
std::env::set_var(wsb::daemon::NO_DAEMON_ENV, "1");
}
if take_flag(&mut argv, "--ignore-workspace-lock") {
std::env::set_var(wsb::workspace_lock::IGNORE_ENV, "1");
}
if jsonl {
wsb::output::jsonl_mode();
std::panic::set_hook(Box::new(|info| {
let where_at = info
.location()
.map(|l| format!(" at {}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_default();
wsb::output::failed(&format!("panicked{where_at}: {}", panic_message(info)), "panic");
}));
}
if let Some(root) = wsb::logging::detect_workspace_root() {
if let Some(path) = wsb::output::attach_transcript(&root, &argv) {
wsb::output::log_file(&path.display().to_string());
}
}
wsb::output::started(&argv);
let debug_mode = argv
.iter()
.any(|arg| arg == "--debug" || arg == "-v" || arg == "--verbose");
let _log_guard = match setup_logging(debug_mode) {
Ok(guard) => guard,
Err(e) => {
wsb::output::failed(&format!("Failed to initialize logging: {e}"), "logging");
exit(wsb::output::finish(1));
}
};
log_version_info(env!("CARGO_PKG_VERSION"), option_env!("GIT_HASH"));
if let Err(e) = run(&argv) {
error!(error = %e, "Application error");
wsb::output::failed(&format!("{}: {:#}", "Error".red(), e), "command");
exit(wsb::output::finish(1));
}
info!("Workspace tool completed successfully");
let _ = wsb::output::finish(0);
}
fn parse_arguments(argv: &[String]) -> Args {
match Args::try_parse_from(argv) {
Ok(args) => args,
Err(error) => {
let text = error.render().to_string();
if matches!(
error.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
wsb::outln!("{text}");
exit(wsb::output::finish(0));
}
wsb::output::failed(&text, "arguments");
exit(wsb::output::finish(2));
}
}
}
fn writes_the_tree(command: &Commands) -> Option<String> {
let named = match command {
Commands::Local { .. } => "local",
Commands::Checkpoint { .. } => "checkpoint",
Commands::Update { .. } => "update",
Commands::Commit { .. } => "commit",
Commands::Wstemplate { .. } => "wstemplate",
Commands::Template { .. } => "template",
Commands::Discard { .. } => "discard",
Commands::Scrap { .. } => "scrap",
_ => return None,
};
Some(named.to_string())
}
fn take_flag(argv: &mut Vec<String>, flag: &str) -> bool {
let before = argv.len();
argv.retain(|arg| arg != flag);
before != argv.len()
}
fn take_no_daemon_flag(argv: &mut Vec<String>) -> bool {
let before = argv.len();
argv.retain(|arg| arg != "--no-daemon");
before != argv.len()
}
fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
if let Some(text) = payload.downcast_ref::<&str>() {
return (*text).to_string();
}
if let Some(text) = payload.downcast_ref::<String>() {
return text.clone();
}
"a panic with no message".to_string()
}
fn run(argv: &[String]) -> Result<()> {
let start_time = Instant::now();
let mut args = parse_arguments(argv);
if args.jsonl {
bail!("--jsonl reached the argument parser: the scan in main must run first");
}
if args.no_daemon {
bail!("--no-daemon reached the argument parser: the scan in main must run first");
}
if args.ignore_workspace_lock {
bail!("--ignore-workspace-lock reached the argument parser: the scan in main must run first");
}
let no_daemon = wsb::daemon::disabled();
let command = args.command.take().unwrap_or(Commands::Serve {
port: None,
listen: None,
debug: false,
workspace: None,
parent_pid: None,
});
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let mut _writing = None;
if let Some(operation) = writes_the_tree(&command) {
if let Ok(root) = workspace::require_project_root(&cwd) {
let root = workspace::find_super_root(&root).unwrap_or(root);
_writing = wsb::workspace_lock::begin_writing(&root, &operation).map_err(|e| anyhow::anyhow!(e))?;
}
}
if let Commands::Init { first_task, name } = command {
let ctx = AppContext::bootstrap_for_init(&cwd)?;
setup_new_project(first_task, name, &ctx)?;
return init_nested_workspaces(&cwd);
}
if let Commands::Check { no_network, quiet } = command {
return run_check_command(&cwd, no_network, quiet);
}
if let Commands::Serve {
port,
listen,
debug,
workspace: serve_workspace,
parent_pid,
} = command
{
let workspace_root = validate_server_workspace(serve_workspace.map(PathBuf::from))?;
return run_serve_command(&workspace_root, port, listen, debug, parent_pid, no_daemon);
}
if let Commands::Daemon { action } = command {
return run_daemon_command(action);
}
if let Commands::Completions { shell } = command {
return install_shell_completions(shell);
}
if let Commands::Github { args } = command {
let code = wsb_github::cli::run_through(args, |stream, line| match stream {
wsb_github::output::Stream::Out => wsb::outln!("{line}"),
wsb_github::output::Stream::Err => wsb::errln!("{line}"),
});
exit(wsb::output::finish(i32::from(code)));
}
if let Commands::Worktree { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "worktree".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::worktree::dispatch(&argv))));
}
if let Commands::Workspace { action: WorkspaceAction::RelocateState } = command {
let cwd = std::env::current_dir()?;
let root = wsb::workspace::find_workspace_root(&cwd).ok_or_else(|| {
anyhow::anyhow!("{} is not inside a wsb workspace (no .wsb/state.json above it)", cwd.display())
})?;
let rewritten = wsb::workspace_state::relocate_state_files(&root)?;
for path in &rewritten {
wsb::outln!("rewrote {}", path.display());
}
wsb::outln!(
"{} state file(s) rewritten; every other one already names no place",
rewritten.len()
);
return Ok(());
}
attach_to_the_daemon(&cwd, no_daemon)?;
if let Commands::Peer { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "peer".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::peer::dispatch(&argv))));
}
if let Commands::Plugin { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "plugin".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::plugin::dispatch(&argv))));
}
let project_root = workspace::require_project_root(&cwd)?;
let ctx = AppContext::bootstrap(&project_root)?;
match command {
Commands::Refactor { args } => {
log_operation_start("refactor", &format!("root: {:?}", args.root_dir));
match wsb::run_refactor(args) {
Ok(()) => log_operation_complete("refactor", start_time.elapsed()),
Err(error) => {
log_operation_error("refactor", &error);
wsb::output::failed(&format!("{}: {:#}", "Error".red(), error), "refactor");
exit(wsb::output::finish(1));
}
}
}
Commands::Discard {
untracked,
yes,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Discard { untracked, yes },
false,
dry_run,
)?;
}
Commands::Status { verbose } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Status, verbose, false)?;
}
Commands::Checkout {
branch,
create,
ignore_missing,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Checkout {
branch,
create,
ignore_missing,
},
verbose,
dry_run,
)?;
}
Commands::Pull {
rebase,
autostash,
overwrite_local,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Pull {
rebase,
autostash,
overwrite_local,
},
verbose,
dry_run,
)?;
}
Commands::Push {
force_with_lease,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Push {
force_with_lease,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Create {
branch,
from,
push,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Create {
branch,
from,
push,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Commit {
message,
push,
stage_all,
exclude,
include,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Commit {
message,
push,
stage_all,
exclude,
include,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Sync {
init,
recursive,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Sync { init, recursive },
verbose,
dry_run,
)?;
}
Commands::Delete {
branch,
remote,
force,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Delete {
branch,
remote,
force,
},
verbose,
dry_run,
)?;
}
Commands::Fetch {
all,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Fetch { all, force_all },
verbose,
dry_run,
)?;
}
Commands::Branch { all, verbose } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Branch { all }, verbose, false)?;
}
Commands::SubmoduleInit {
recursive,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Init { recursive },
verbose,
dry_run,
)?;
}
Commands::Diff {
base,
since,
output,
staged,
clean,
verbose,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Diff {
base,
since,
output,
staged,
clean,
},
verbose,
false,
)?;
}
Commands::Stage { verbose, dry_run } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Stage, verbose, dry_run)?;
}
Commands::MergeBack {
message,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::MergeBack { message },
verbose,
dry_run,
)?;
}
Commands::Template { action } => {
handle_template_command(action, &ctx)?;
}
Commands::Completions { .. } => {
unreachable!("Completions is handled before the workspace guard")
}
Commands::Local { action } => {
run_local_command(action, &ctx.project_root, start_time)?;
}
Commands::Checkpoint {
message,
no_push,
dry_run,
} => {
log_operation_start(
"checkpoint",
&format!("no_push: {no_push}, dry_run: {dry_run}"),
);
run_checkpoint_command(&message, no_push, dry_run, &ctx.project_root)?;
log_operation_complete("checkpoint", start_time.elapsed());
}
Commands::Update {
no_git,
git_add,
version_suffix,
tag,
quiet,
recursive,
verify,
discard,
dry_run: discard_dry_run,
} => {
if discard {
log_operation_start(
"update discard",
&format!("dry_run: {discard_dry_run}, recursive: {recursive}"),
);
if recursive {
discard_recursive(&ctx.project_root, discard_dry_run)?;
} else {
run_update_discard(&ctx.project_root, discard_dry_run, &RunCache::new())?;
}
log_operation_complete("update discard", start_time.elapsed());
return Ok(());
}
refuse_update_in_local_mode(&ctx.project_root)?;
log_operation_start(
"update",
&format!(
"no_git: {}, git_add: {}, version_suffix: {:?}, tag: {:?}, recursive: {}",
no_git, git_add, version_suffix, tag, recursive
),
);
if recursive {
update_recursive(
no_git,
git_add,
version_suffix.as_deref(),
tag.as_deref(),
&ctx.project_root,
quiet,
verify,
)?;
} else {
update_state(
no_git,
git_add,
version_suffix.as_deref(),
tag.as_deref(),
&ctx.project_root,
quiet,
&RunCache::new(),
verify,
)?;
}
log_operation_complete("update", start_time.elapsed());
}
Commands::Scrap { paths, command } => {
run_scrap_command(paths, command)?;
}
Commands::Unscrap { name, force, to } => {
run_unscrap_command(name, force, to)?;
}
Commands::Ldiff { substitute_char } => {
run_ldiff_command(substitute_char)?;
}
Commands::Compact {
input,
output,
token_mode,
} => {
run_compact_command(input, output, token_mode)?;
}
Commands::AuditPath => {
run_audit_path_command()?;
}
Commands::Ignore { line } => {
run_ignore_command(&line)?;
}
Commands::LastHash { file } => {
run_last_hash_command(&file)?;
}
Commands::Code { action } => {
let action = action.unwrap_or(CodeAction::Tree {
depth: 3,
hidden: false,
sizes: false,
extensions: None,
no_ignore: false,
});
handle_code_command(action)?;
}
Commands::Test {
dry_run,
install,
args,
} => {
handle_test_command(dry_run, install, args, &ctx.project_root)?;
}
Commands::Sample {
project,
data,
force,
output,
} => {
run_sample_command(project, data, force, output, &ctx)?;
}
Commands::Github { .. } => unreachable!("Github is handled before this match"),
Commands::Workspace { .. } => unreachable!("Workspace is handled before this match"),
Commands::Peer { .. } => unreachable!("Peer is handled before this match"),
Commands::Plugin { .. } => unreachable!("Plugin is handled before this match"),
Commands::Worktree { .. } => unreachable!("Worktree is handled before this match"),
Commands::Serve { .. } => unreachable!("Serve is handled before this match"),
Commands::Daemon { .. } => {
unreachable!("Daemon is handled before this match, so it never starts one")
}
Commands::Relationship { action } => {
run_relationship_command(action, &ctx)?;
}
Commands::Note { action } => {
run_note_command(action, &ctx)?;
}
Commands::Database { action } => {
run_database_command(action, &ctx)?;
}
Commands::Continuity { action } => {
run_continuity_command(action, &ctx)?;
}
Commands::Version { action } => {
run_version_command(action, &ctx)?;
}
Commands::Wstemplate { action } => {
handle_wstemplate_command(action, &ctx.project_root)?;
}
Commands::Each { file, quiet } => {
run_each_command(&ctx.project_root, file, quiet)?;
}
Commands::Catalog { action } => run_catalog_command(action, &ctx)?,
Commands::Init { .. } | Commands::Check { .. } => unreachable!(),
}
Ok(())
}
fn run_fleet_or_repo_command(
command: wsb::fleet::cli::Commands,
verbose: bool,
dry_run: bool,
) -> Result<()> {
use wsb::fleet::output::Formatter;
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let tree = wsb::fleet::git::SubmoduleTree::discover_allow_empty(&cwd)
.map_err(|error| anyhow::anyhow!("{}", error))?;
for pattern in tree.unmatched_ignore_patterns() {
Formatter::print_warning(&format!(
"ignored_submodules pattern '{}' matched no submodule",
pattern
));
}
if tree.is_empty() {
if !tree.ignored().is_empty() {
Formatter::print_info(&format!(
"All {} submodule(s) are ignored; operating on this repository only",
tree.ignored().len()
));
}
execute_repo_git_command(command, verbose, dry_run)?;
} else {
wsb::fleet::execute(command, verbose, dry_run)?;
}
Ok(())
}
fn execute_repo_git_command(
command: wsb::fleet::cli::Commands,
_verbose: bool,
dry_run: bool,
) -> Result<()> {
use wsb::fleet::cli::Commands;
use wsb::fleet::git::{GitOps, SubmoduleStatus};
use wsb::fleet::output::Formatter;
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let repo_root = wsb::git_backend::repo_root(&cwd)?;
let repo = wsb::git_backend::open_repo(&repo_root)?;
match command {
Commands::Status => {
let is_detached =
GitOps::is_detached_head(&repo).map_err(|e| anyhow::anyhow!("{}", e))?;
let current_branch = if is_detached {
None
} else {
Some(GitOps::current_branch(&repo).map_err(|e| anyhow::anyhow!("{}", e))?)
};
let rendered = wsb::state::discard::rendered_as_written(
&wsb::workspace::find_super_root_or_project(&repo_root),
&wsb::state::RunCache::new(),
)?;
let (changed, moved_pointers) = GitOps::uncommitted_files_and_pointers(&repo)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let (uncommitted_files, rendered_files) =
wsb::fleet::git::split_rendered(&repo_root, changed, &rendered);
let is_clean = uncommitted_files.is_empty();
let (ahead, behind) = if is_detached {
(0, 0)
} else {
GitOps::get_ahead_behind(&repo).unwrap_or((0, 0))
};
let repo_name = repo_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("[REPOSITORY]")
.to_string();
Formatter::print_status(&[SubmoduleStatus {
name: repo_name,
path: ".".to_string(),
current_branch,
is_clean,
is_detached,
ahead,
behind,
uncommitted_files,
rendered_files,
moved_pointers,
exists: true,
depth: 0,
}]);
}
Commands::Discard { untracked, yes } => {
wsb::fleet::commands::discard::execute(
&repo_root,
wsb::fleet::commands::discard::Options {
untracked,
assume_yes: yes,
dry_run,
},
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
Commands::Checkout {
branch,
create,
ignore_missing,
} => {
let local_branch_exists = wsb::git_backend::branch_exists(
&repo,
&branch,
wsb::git_backend::BranchKind::Local,
)?;
let remote_branch_exists = if !local_branch_exists && !create {
wsb::git_backend::branch_exists(
&repo,
&format!("origin/{branch}"),
wsb::git_backend::BranchKind::Remote,
)?
} else {
false
};
let remote_branch_advertised =
if !local_branch_exists && !remote_branch_exists && !create {
wsb::git_backend::remote_branch_head(&repo, "origin", &branch)?.is_some()
} else {
false
};
let branch_available =
local_branch_exists || remote_branch_exists || remote_branch_advertised;
if !branch_available && !create && ignore_missing {
Formatter::print_info(&format!(
"Branch '{}' does not exist; skipping checkout",
branch
));
return Ok(());
}
if !branch_available && !create {
anyhow::bail!(
"Branch '{}' does not exist. Use --create to create it.",
branch
);
}
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if local_branch_exists {
Formatter::print_info(&format!("Would checkout '{}'", branch));
} else if remote_branch_exists {
Formatter::print_info(&format!("Would checkout '{}' from remote", branch));
} else if remote_branch_advertised {
Formatter::print_info(&format!(
"Would fetch and checkout '{}' from remote",
branch
));
} else if create {
Formatter::print_info(&format!("Would create and checkout '{}'", branch));
} else {
Formatter::print_info(&format!("Would checkout '{}'", branch));
}
return Ok(());
}
if create && !local_branch_exists {
wsb::git_backend::create_branch(&repo, &branch, None)?;
} else {
if remote_branch_advertised {
wsb::git_backend::fetch_remote_branch(&repo, "origin", &branch)?;
}
wsb::git_backend::checkout_branch(&repo, &branch)?;
}
Formatter::print_success(&format!("Checked out '{}'", branch));
}
Commands::Pull {
rebase,
autostash,
overwrite_local,
} => {
let options = wsb::git_backend::PullOptions {
rebase,
autostash,
overwrite_local,
};
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
Formatter::print_info(&format!("Branch is {}", state.describe()));
return Ok(());
}
let label = wsb::git_backend::current_branch(&repo)?;
match wsb::fleet::commands::pull::pull_one(&repo, &label, options) {
Ok(wsb::fleet::commands::pull::Verdict::Changed(message)) => {
Formatter::print_success(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Unchanged(message)) => {
Formatter::print_info(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Blocked(message))
| Err(message) => return Err(anyhow::anyhow!(message)),
}
}
Commands::Push {
force_with_lease,
force_all,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
if force_all || state.has_outgoing() {
Formatter::print_info("Would push current branch");
} else {
Formatter::print_info(&format!("Branch is {}; would skip push", state.describe()));
}
return Ok(());
}
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
if let wsb::git_backend::RemoteState::Diverged { incoming, outgoing } = state {
return Err(anyhow::anyhow!(
"the branch has diverged: {outgoing} local commit(s) and {incoming} \
incoming — pull (or `wsb pull --rebase`) before pushing"
));
}
if !force_all && !state.has_outgoing() {
Formatter::print_info(&format!("Branch is {}; skipping push", state.describe()));
return Ok(());
}
match wsb::git_backend::push_current_branch(
&repo,
Some("origin"),
true,
force_with_lease,
)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed current branch");
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("Current branch is already up to date");
}
}
}
Commands::Create {
branch,
from,
push,
force_all,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || !wsb::git_backend::is_clean(&repo)? {
Formatter::print_info(&format!(
"Would create branch '{}'{}",
branch,
from.as_deref()
.map(|base| format!(" from '{}'", base))
.unwrap_or_default()
));
} else {
Formatter::print_info("No worktree changes; would skip branch creation");
}
return Ok(());
}
if !force_all && wsb::git_backend::is_clean(&repo)? {
Formatter::print_info("No worktree changes; skipping branch creation");
return Ok(());
}
wsb::git_backend::create_branch(&repo, &branch, from.as_deref())?;
Formatter::print_success(&format!("Created branch '{}'", branch));
if push {
match wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed new branch");
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("New branch is already up to date");
}
}
}
}
Commands::Commit {
message,
push,
stage_all,
force_all,
..
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || !wsb::git_backend::is_clean(&repo)? {
if stage_all {
Formatter::print_info("Would stage all changes (git add -A)");
}
Formatter::print_info(&format!(
"Would commit changes with message '{}'",
message
));
} else {
Formatter::print_info("No worktree changes; would skip commit");
}
return Ok(());
}
if !force_all && wsb::git_backend::is_clean(&repo)? {
Formatter::print_info("No worktree changes; skipping commit");
return Ok(());
}
if stage_all {
wsb::git_backend::stage_all(&repo)?;
}
match wsb::git_backend::commit_all(&repo, &message)? {
Some(sha) => Formatter::print_success(&format!("Committed changes: {}", sha)),
None => Formatter::print_info("Nothing to commit"),
}
if push {
match wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed commit")
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("Current branch is already up to date")
}
}
}
}
Commands::Sync { init, recursive } => {
if init || recursive {
anyhow::bail!(
"'wsb sync --init/--recursive' requires a fleet root with a .gitmodules file"
);
}
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would fetch and pull current branch");
return Ok(());
}
let label = wsb::git_backend::current_branch(&repo)?;
match wsb::fleet::commands::pull::pull_one(
&repo,
&label,
wsb::git_backend::PullOptions::default(),
) {
Ok(wsb::fleet::commands::pull::Verdict::Changed(message)) => {
Formatter::print_success(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Unchanged(message)) => {
Formatter::print_info(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Blocked(message))
| Err(message) => return Err(anyhow::anyhow!(message)),
}
}
Commands::Delete {
branch,
remote,
force,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info(&format!("Would delete branch '{}'", branch));
if remote {
Formatter::print_info(&format!("Would delete remote branch '{}'", branch));
}
return Ok(());
}
wsb::git_backend::delete_local_branch(&repo, &branch, force)?;
Formatter::print_success(&format!("Deleted branch '{}'", branch));
if remote {
wsb::git_backend::delete_remote_branch(&repo, Some("origin"), &branch)?;
Formatter::print_success(&format!("Deleted remote branch '{}'", branch));
}
}
Commands::Fetch { all, force_all } => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || wsb::git_backend::has_fetchable_updates(&repo, all)? {
Formatter::print_info("Would fetch remote updates");
} else {
Formatter::print_info("No fetchable remote updates; would skip fetch");
}
return Ok(());
}
if !force_all && !wsb::git_backend::has_fetchable_updates(&repo, all)? {
Formatter::print_info("No fetchable remote updates; skipping fetch");
return Ok(());
}
if all {
wsb::git_backend::fetch_all(&repo)?;
} else {
wsb::git_backend::fetch(&repo, Some("origin"))?;
}
Formatter::print_success("Fetched remote updates");
}
Commands::Branch { all } => {
let current = wsb::git_backend::current_branch(&repo).ok();
let branches = if all {
let mut local = wsb::git_backend::local_branches(&repo)?;
local.extend(wsb::git_backend::remote_branches(&repo)?);
local
} else {
wsb::git_backend::local_branches(&repo)?
};
Formatter::print_branches("[REPOSITORY]", &branches, current.as_deref());
}
Commands::Init { recursive: _ } => {
anyhow::bail!("'wsb submodule-init' requires a fleet root with a .gitmodules file");
}
Commands::Diff {
base,
since,
output,
staged,
clean,
} => {
let outdir = std::path::Path::new(&output);
if clean && outdir.exists() {
std::fs::remove_dir_all(outdir).map_err(|e| {
anyhow::anyhow!("Failed to remove '{}': {}", outdir.display(), e)
})?;
}
let files = wsb::fleet::commands::diff::generate_per_file_diffs(
&repo_root,
base.as_deref(),
since.as_deref(),
outdir,
staged,
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if files.is_empty() {
Formatter::print_info("No changes to diff");
} else {
Formatter::print_success(&format!(
"{} diff file(s) written to '{}'",
files.len(),
output
));
for file in &files {
wsb::outln!(" {}.diff", file);
}
}
}
Commands::Stage => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would stage all changes");
return Ok(());
}
wsb::git_backend::stage_all(&repo)?;
Formatter::print_success("Staged all changes");
}
Commands::MergeBack { message } => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would merge current branch back into the default branch");
return Ok(());
}
let current_branch = wsb::git_backend::current_branch(&repo)?;
let default_branch = wsb::git_backend::get_default_branch(&repo)?;
if current_branch == default_branch {
anyhow::bail!("already on '{}'", default_branch);
}
if !wsb::git_backend::is_clean(&repo)? {
wsb::git_backend::stage_all(&repo)?;
}
let _ = wsb::git_backend::commit_all(&repo, &message)?;
let _ = wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)?;
wsb::git_backend::checkout_branch(&repo, &default_branch)?;
wsb::git_backend::merge_ff(&repo, ¤t_branch)?;
let _ = wsb::git_backend::push_current_branch(&repo, Some("origin"), false, false)?;
Formatter::print_success("Merge-back complete");
}
Commands::TestCatalog {
output,
git_add,
quiet,
} => {
wsb::fleet::commands::catalogue::execute(
&repo_root,
output.as_deref(),
git_add,
quiet,
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
}
Ok(())
}
fn make_spinner(quiet: bool, msg: &'static str) -> indicatif::ProgressBar {
if quiet || wsb::output::is_jsonl() {
indicatif::ProgressBar::hidden()
} else {
let pb = indicatif::ProgressBar::new_spinner();
pb.set_style(
indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.set_message(msg);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
}
fn make_progress_bar(quiet: bool, total: u64) -> indicatif::ProgressBar {
if quiet || wsb::output::is_jsonl() {
indicatif::ProgressBar::hidden()
} else {
let pb = indicatif::ProgressBar::new(total);
pb.set_style(
indicatif::ProgressStyle::with_template("{spinner:.cyan} [{pos}/{len}] {wide_msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
}
fn local_rendering(project_root: &Path) -> Result<wsb::state::stamp_config::LocalRendering> {
wsb::state::stamp_config::StampConfig::local(&workspace_root_of(project_root))
}
fn declared_local_rendering(
project_root: &Path,
) -> Result<Option<wsb::state::stamp_config::LocalRendering>> {
Ok(wsb::state::stamp_config::StampConfig::load(&workspace_root_of(project_root))?
.and_then(|config| config.local))
}
fn workspace_root_of(project_root: &Path) -> PathBuf {
wsb::workspace::find_super_root(project_root).unwrap_or_else(|| project_root.to_path_buf())
}
fn run_declared(argv: &[String], root: &Path, what: &str) -> Result<()> {
let (program, arguments) = argv
.split_first()
.ok_or_else(|| anyhow::anyhow!("the declared {what} command is empty"))?;
let status = wsb::git_backend::program_without_repo_scope(program)
.args(arguments)
.current_dir(root)
.status()
.with_context(|| format!("cannot run the declared {what} command `{}`", argv.join(" ")))?;
if !status.success() {
bail!("the declared {what} command `{}` failed ({status})", argv.join(" "));
}
Ok(())
}
fn run_local_command(action: LocalAction, project_root: &Path, start_time: Instant) -> Result<()> {
let local = local_rendering(project_root)?;
std::env::set_var(&local.variable, "1");
match action {
LocalAction::On {
quiet,
git_add,
verify,
} => {
log_operation_start("local on", &format!("git_add: {git_add}, verify: {verify}"));
render_local(project_root, &local, git_add, quiet, verify)?;
if !quiet {
wsb::outln!(
"Manifests point at the working copies here. \
Run 'wsb local off' before committing."
);
}
log_operation_complete("local on", start_time.elapsed());
}
LocalAction::Off { dry_run } => {
log_operation_start("local off", &format!("dry_run: {dry_run}"));
discard_recursive(project_root, dry_run)?;
ensure_not_local(project_root, &local.variable, dry_run)?;
if !dry_run {
release_what_the_local_render_named(project_root, &local)?;
}
log_operation_complete("local off", start_time.elapsed());
}
LocalAction::Status { files } => {
log_operation_start("local status", &format!("files: {files}"));
report_local_status(project_root, &local.variable, files)?;
log_operation_complete("local status", start_time.elapsed());
}
}
Ok(())
}
fn render_local(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
git_add: bool,
quiet: bool,
verify: bool,
) -> Result<()> {
update_recursive(false, git_add, None, None, project_root, quiet, verify)?;
if let Err(built) = build_what_the_local_render_names(project_root, local) {
return match discard_recursive(project_root, false) {
Ok(()) => Err(anyhow::anyhow!("{built:#}\n The render was taken back: the tree is not local.")),
Err(discard) => Err(anyhow::anyhow!(
"{built:#}\n and taking the render back failed: {discard:#}\n Finish it with: wsb local off"
)),
};
}
Ok(())
}
fn build_what_the_local_render_names(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
) -> Result<()> {
let root = workspace_root_of(project_root);
if !wsb::state::local_render::local_render_reads_build_root(&root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?
{
return Ok(());
}
match &local.prepare {
Some(argv) => run_declared(argv, &root, "local prepare"),
None => Ok(()),
}
}
fn release_what_the_local_render_named(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
) -> Result<()> {
let root = workspace_root_of(project_root);
if !wsb::state::local_render::local_render_reads_build_root(&root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?
{
return Ok(());
}
match &local.release {
Some(argv) => run_declared(argv, &root, "local release"),
None => Ok(()),
}
}
enum LocalState {
Local,
Committed,
Indifferent,
Edited,
Absent,
}
fn report_local_status(project_root: &Path, variable: &str, list_files: bool) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> = tree
.bottom_up()
.iter()
.map(|node| node.abs_path.clone())
.collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut order: Vec<PathBuf> = Vec::new();
let mut landing: HashMap<PathBuf, String> = HashMap::new();
for repository in &repositories {
if !owns_templates(repository, &boundaries, &cache)? {
continue;
}
let planned = wsb::state::discard::planned_outputs_with_cache(repository, &cache)
.with_context(|| format!("reading {}", repository.display()))?;
for (path, rendered) in planned {
if landing.insert(path.clone(), rendered).is_none() {
order.push(path);
}
}
}
let (mut local, mut committed, mut edited, mut absent, mut indifferent) =
(0usize, 0usize, 0usize, 0usize, 0usize);
let mut unsettled: Vec<String> = Vec::new();
for path in &order {
let rendered = &landing[path];
let shown = path
.strip_prefix(project_root)
.unwrap_or(path.as_path())
.display()
.to_string();
let worktree = match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
wsb::errln!(" unreadable {shown} — {error}");
continue;
}
};
let head = wsb::state::discard::head_text(path);
let indistinguishable = head.as_deref() == Some(rendered.as_str());
let state = match worktree {
None => LocalState::Absent,
Some(_) if indistinguishable => LocalState::Indifferent,
Some(worktree) if &worktree == rendered => LocalState::Local,
Some(worktree) if head.as_deref() == Some(worktree.as_str()) => LocalState::Committed,
Some(_) => LocalState::Edited,
};
let label = match state {
LocalState::Local => {
local += 1;
"local"
}
LocalState::Committed => {
committed += 1;
"as committed"
}
LocalState::Indifferent => {
indifferent += 1;
"same either way"
}
LocalState::Absent => {
absent += 1;
unsettled.push(format!(" not written yet {shown}"));
"not written yet"
}
LocalState::Edited => {
edited += 1;
unsettled.push(format!(" neither {shown}"));
"neither"
}
};
if list_files {
wsb::outln!(" {:<16} {shown}", label);
}
}
let total = local + committed + edited + absent + indifferent;
if total == 0 {
wsb::outln!("No repository under here has anything a render writes.");
return Ok(());
}
let deciding = local + committed + edited + absent;
if deciding == 0 {
wsb::outln!(
"Nothing here tells the two states apart: all {total} generated file(s) render the \
same with and without {variable}."
);
return Ok(());
}
wsb::outln!(
"{local} of {deciding} file(s) that tell the two states apart carry the local render; \
{committed} stand as committed."
);
if indifferent > 0 {
wsb::outln!(" ({indifferent} more render the same either way.)");
}
for line in &unsettled {
wsb::outln!("{line}");
}
if edited + absent == 0 {
if local == 0 {
wsb::outln!(
"This tree is as committed. 'wsb local on' points it at the working copies."
);
} else if committed == 0 {
wsb::outln!("This tree is local. 'wsb local off' takes that back.");
}
}
Ok(())
}
fn refuse_update_in_local_mode(project_root: &Path) -> Result<()> {
let Some(local) = declared_local_rendering(project_root)? else {
return Ok(());
};
if std::env::var_os(&local.variable).is_some_and(|v| !v.is_empty()) {
return Ok(());
}
let found = wsb::state::local_render::scan_workspace(project_root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if found.is_empty() {
return Ok(());
}
Err(anyhow::anyhow!(
"this workspace is in local mode: {} file(s) are rendered against the working copies \
(first: {}). A plain update would render what it reaches against published tags and \
leave the rest local.\n \
Refresh the local render: wsb local on\n \
Leave local mode: wsb local off",
found.len(),
found[0].message()
))
}
fn ensure_not_local(project_root: &Path, variable: &str, dry_run: bool) -> Result<()> {
let root = wsb::workspace::find_super_root(project_root)
.unwrap_or_else(|| project_root.to_path_buf());
let found =
wsb::state::local_render::scan_workspace(project_root, variable).map_err(|e| anyhow::anyhow!("{e}"))?;
if found.is_empty() {
return Ok(());
}
wsb::outln!(
"── {} file(s) are still local after the take-back ──",
found.len()
);
use wsb::state::local_render::Persistence;
let why: Vec<Persistence> = found
.iter()
.map(|one| wsb::state::local_render::persistence(&root, one, variable))
.collect();
for (one, state) in found.iter().zip(&why).take(5) {
wsb::outln!(" {} ({})", one.message(), state.describe());
}
if found.len() > 5 {
wsb::outln!(" … and {} more", found.len() - 5);
}
let committed = why.iter().filter(|s| **s == Persistence::Committed).count();
if committed > 0 {
wsb::outln!(
" {committed} of them are committed that way, so restoring what is committed \
restores it and every checkpoint re-pushes it. Those repositories need the \
fresh render COMMITTED, not just written."
);
}
wsb::outln!(" Rendering fresh against published tags instead.");
if dry_run {
wsb::outln!(" would re-render the fleet with {variable} unset");
return Ok(());
}
std::env::remove_var(variable);
update_recursive(false, false, None, None, &root, true, false)?;
let left = wsb::state::local_render::scan_workspace(project_root, variable)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !left.is_empty() {
bail!(
"{} file(s) still carry the local render after a fresh render:\n{}\n \
Something outside the templates is writing them, and committing this tree \
would publish a dependency that resolves on this machine only.",
left.len(),
left.iter()
.take(10)
.map(|one| format!(" {}", one.message()))
.collect::<Vec<_>>()
.join("\n")
);
}
wsb::outln!(" the tree is no longer local");
Ok(())
}
fn run_checkpoint_command(
message: &str,
no_push: bool,
dry_run: bool,
project_root: &Path,
) -> Result<()> {
let local = declared_local_rendering(project_root)?;
if let Some(local) = &local {
std::env::set_var(&local.variable, "1");
wsb::outln!("── taking the local render back ──");
discard_recursive(project_root, dry_run)?;
ensure_not_local(project_root, &local.variable, dry_run)?;
std::env::remove_var(&local.variable);
}
wsb::outln!("── bringing main in ──");
wsb::fleet::commands::pull::bring_main_in(project_root, dry_run).map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("── committing ──");
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Commit {
message: message.to_string(),
push: false,
stage_all: true,
exclude: Vec::new(),
include: Vec::new(),
force_all: false,
},
false,
dry_run,
)?;
let pushed = if no_push {
wsb::outln!("── not pushing (--no-push) ──");
Ok(())
} else {
wsb::outln!("── pushing ──");
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Push {
force_with_lease: false,
force_all: false,
},
false,
dry_run,
)
};
if let Some(local) = &local {
wsb::outln!("── going local again ──");
std::env::set_var(&local.variable, "1");
if dry_run {
wsb::outln!(" would render the fleet with {} set", local.variable);
} else {
render_local(project_root, local, false, true, false)?;
}
wsb::outln!("Manifests point at the working copies here.");
}
pushed.map_err(|e| {
anyhow::anyhow!("{e:#}\n The commits are made; push again with: wsb push")
})
}
fn update_recursive(
no_git: bool,
git_add: bool,
version_suffix: Option<&str>,
tag: Option<&str>,
project_root: &Path,
quiet: bool,
verify: bool,
) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> =
tree.bottom_up().iter().map(|node| node.abs_path.clone()).collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut updated = 0usize;
let mut skipped: Vec<String> = Vec::new();
for repository in &repositories {
let label = match repository.strip_prefix(project_root) {
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Ok(relative) => relative.display().to_string(),
Err(_) => repository.display().to_string(),
};
if !owns_templates(repository, &boundaries, &cache)? {
skipped.push(label);
continue;
}
if !quiet {
wsb::outln!("── {label} ──");
}
update_state(
no_git,
git_add,
version_suffix,
tag,
repository,
quiet,
&cache,
verify,
)
.with_context(|| format!("updating {label}"))?;
updated += 1;
}
if !quiet {
wsb::outln!("update: {updated} repository(ies) updated");
if !skipped.is_empty() {
wsb::outln!(
" no templates of their own, so untouched: {}",
skipped.join(" ")
);
}
}
Ok(())
}
fn discard_recursive(project_root: &Path, dry_run: bool) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> = tree
.bottom_up()
.iter()
.map(|node| node.abs_path.clone())
.collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut visited = 0usize;
for repository in &repositories {
if !owns_templates(repository, &boundaries, &cache)? {
continue;
}
let label = match repository.strip_prefix(project_root) {
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Ok(relative) => relative.display().to_string(),
Err(_) => repository.display().to_string(),
};
wsb::outln!("── {label} ──");
run_update_discard(repository, dry_run, &cache)?;
visited += 1;
}
if visited == 0 {
wsb::outln!("No repository under here has anything an update writes.");
}
Ok(())
}
fn run_update_discard(project_root: &Path, dry_run: bool, cache: &Arc<RunCache>) -> Result<()> {
use wsb::state::discard::Outcome;
let report = wsb::state::discard::run_with_cache(project_root, dry_run, cache)?;
let verb = if dry_run { "would take back" } else { "took back" };
for file in &report.files {
let shown = file
.path
.strip_prefix(project_root)
.unwrap_or(&file.path)
.display();
match &file.outcome {
Outcome::Reverted => wsb::outln!(" {verb} {shown}"),
Outcome::Partial { kept } => wsb::outln!(
" {verb} {shown} — {kept} region(s) left alone, yours and the update's edits \
are on the same lines"
),
Outcome::Removed => wsb::outln!(" {verb} {shown} (created by the update, removed)"),
Outcome::Unresolvable { reason } => {
wsb::errln!(" refused {shown} — {reason}")
}
Outcome::Untouched => {}
}
}
let changed = report.changed();
let refused = report
.files
.iter()
.filter(|f| matches!(f.outcome, Outcome::Unresolvable { .. }))
.count();
let partial = report.partial().count();
if changed == 0 && refused == 0 {
wsb::outln!("Nothing to take back: no file carries changes from an update.");
return Ok(());
}
wsb::outln!(
"{} {changed} file(s) of {} the update writes.",
if dry_run { "Would change" } else { "Changed" },
report.files.len()
);
if partial > 0 {
wsb::outln!(
"{partial} file(s) still hold an update's edit on a line you also changed — those \
lines are yours and were not touched."
);
}
if refused > 0 {
wsb::errln!("{refused} file(s) were refused; nothing was written to them.");
}
Ok(())
}
fn update_state(
no_git: bool,
git_add: bool,
version_suffix: Option<&str>,
tag: Option<&str>,
project_root: &Path,
quiet: bool,
cache: &Arc<RunCache>,
verify: bool,
) -> Result<()> {
if !no_git && !is_git_repository_in(project_root) {
anyhow::bail!(
"{} is not a git repository. Run 'wsb init' there first, or use 'wsb update --no-git' for a non-git directory.",
project_root.display()
);
}
let pb = make_spinner(quiet, "loading configuration…");
let config = StateConfig::load(project_root)?;
let workspace_state = WorkspaceState::load(project_root)?;
if let Some(t) = tag {
wsb::state::validate_git_tag(t)?;
}
pb.set_message("calculating version…");
if git_add {
cache.commit_is_being_made_in(project_root);
}
let mut version_info = if no_git {
calculate_version_without_git(project_root)?
} else {
calculate_version(project_root, cache)?
};
if verify && !no_git {
let fresh = RunCache::new();
if git_add {
fresh.commit_is_being_made_in(project_root);
}
let alone = calculate_version(project_root, &fresh)?;
anyhow::ensure!(
alone.full_version == version_info.full_version,
"--verify: the run's cache calculated version {} for {}, a cache of its own {}",
version_info.full_version,
project_root.display(),
alone.full_version
);
}
version_info.full_version = workspace_state.format_version(&version_info, tag.or(version_suffix));
pb.set_message(format!("writing {} → {}…", "version", &config.version_file));
update_version_file_in_repo(&version_info, &config, project_root)?;
if !config.version_file.is_empty() {
info!("Updated version file: {}", config.version_file);
if !quiet {
pb.println(format!(" {} updated {}", "✓".green(), config.version_file));
}
}
pb.set_message("rendering .tera templates…");
let template_manager = TemplateManager::new(&workspace_state)?;
let project_name = workspace_state.project_name.as_deref();
let rendered_tera_files = template_manager.render_all_templates(&version_info, project_name)?;
if !rendered_tera_files.is_empty() {
info!(
"Rendered {} .tera templates: {}",
rendered_tera_files.len(),
rendered_tera_files.join(", ")
);
if !quiet {
pb.println(format!(
" {} rendered {} .tera template(s)",
"✓".green(),
rendered_tera_files.len()
));
for file in &rendered_tera_files {
pb.println(format!(" {}", file));
}
}
}
let full_version = version_info.full_version.clone();
pb.set_message("rendering .wstemplate files…");
let mut rendered_wstemplate_files: Vec<String> = Vec::new();
{
let entry = workspace_state.effective_wstemplate_entry();
let engine = WstemplateEngine::with_cache(
version_info.clone(),
workspace_state.project_name.clone(),
entry.alias.clone(),
project_root.to_path_buf(),
entry.root.clone(),
Arc::clone(cache),
);
let produced = engine.produce_relevant()?;
if verify {
let alone = WstemplateEngine::with_cache(
version_info,
workspace_state.project_name.clone(),
entry.alias,
project_root.to_path_buf(),
entry.root,
RunCache::new(),
);
compare_renders(&produced, &alone.produce_relevant()?, project_root)?;
}
let rendered = engine.write_produced(produced)?;
for r in &rendered {
info!(
"wstemplate: {} -> {}",
r.source_path.display(),
r.output_path.display()
);
rendered_wstemplate_files.push(r.output_path.display().to_string());
if let Some(lock) = &r.lockfile {
if wsb::git_backend::is_tracked(lock)? {
rendered_wstemplate_files.push(lock.display().to_string());
}
}
}
if !rendered.is_empty() && !quiet {
pb.println(format!(
" {} rendered {} .wstemplate file(s)",
"✓".green(),
rendered.len()
));
}
}
pb.set_message("saving workspace state…");
workspace_state.save(&project_root)?;
if !no_git && git_add && is_git_repository_in(project_root) {
pb.set_message("staging files…");
let mut files_to_add = Vec::new();
if !config.version_file.is_empty() {
files_to_add.push(config.version_file.clone());
}
for file in collect_project_files_for_git_add(&project_root, &config)? {
if !files_to_add.contains(&file) {
files_to_add.push(file);
}
}
for file in &rendered_tera_files {
if !files_to_add.contains(file) {
files_to_add.push(file.clone());
}
}
for file in &rendered_wstemplate_files {
if !files_to_add.contains(file) {
files_to_add.push(file.clone());
}
}
if !files_to_add.is_empty() {
let added_files = add_files_to_git(&files_to_add, &project_root)?;
if !added_files.is_empty() {
info!(
"Added {} files to git staging area: {}",
added_files.len(),
added_files.join(", ")
);
if !quiet {
pb.println(format!(
" {} staged {} file(s)",
"✓".green(),
added_files.len()
));
for file in &added_files {
pb.println(format!(" {}", file));
}
}
}
}
}
if let Some(t) = tag {
if is_git_repository_in(project_root) {
pb.set_message(format!("creating tag {}…", t));
let tag_name = format!("v{}", full_version);
let tag_message = format!("Tagged version {}", full_version);
let repo = wsb::git_backend::discover_repo(project_root)?;
wsb::git_backend::create_tag(&repo, &tag_name, &tag_message)?;
cache.forget_repo(project_root);
info!("Created git tag: {} (tag: {})", tag_name, t);
if !quiet {
pb.println(format!(
" {} created tag {}",
"✓".green(),
tag_name.green().bold()
));
}
} else {
anyhow::bail!("--tag requires a git repository; {} is not one", project_root.display());
}
}
pb.finish_and_clear();
if !quiet {
wsb::outln!("{} {}", "✓".green().bold(), full_version.bold());
}
Ok(())
}
fn collect_project_files_for_git_add(
project_root: &Path,
config: &StateConfig,
) -> Result<Vec<String>> {
let mut files = Vec::new();
if config.auto_detect_project_files {
for project_file in detect_project_files(project_root)? {
let path = project_file.path.to_string_lossy().to_string();
if !files.contains(&path) {
files.push(path);
}
}
}
for configured_path in &config.project_files {
let full_path = project_root.join(configured_path);
if full_path.exists() {
let path = full_path.to_string_lossy().to_string();
if !files.contains(&path) {
files.push(path);
}
}
}
Ok(files)
}
fn handle_wstemplate_command(action: WstemplateAction, project_root: &Path) -> Result<()> {
let mut workspace_state = WorkspaceState::load(project_root)?;
match action {
WstemplateAction::Add { path, alias } => {
let scan_root = wsb::platform::canonicalize(&path)
.with_context(|| format!("Cannot resolve path: {}", path.display()))?;
let alias = alias.unwrap_or_else(|| WstemplateEngine::derive_alias(&project_root));
anyhow::ensure!(
!alias.is_empty()
&& alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !alias.starts_with(|c: char| c.is_ascii_digit()),
"Invalid alias '{}': must match [a-z_][a-z0-9_]* (Tera identifier)",
alias
);
let entry = WstemplateEntry {
alias: alias.clone(),
root: scan_root.clone(),
};
workspace_state.set_wstemplate_entry(entry);
workspace_state.save(&project_root)?;
wsb::outln!("{} wstemplate entries:", "1".bold());
wsb::outln!(" {} \u{2192} {}", alias, scan_root.display());
}
WstemplateAction::Remove { alias } => match workspace_state.wstemplate_entry() {
Some(entry) if entry.alias == alias => {
workspace_state.clear_wstemplate_entry();
workspace_state.save(&project_root)?;
wsb::outln!("Removed wstemplate entry '{}'", alias);
}
Some(entry) => {
anyhow::bail!(
"No entry with alias '{}'. This project's alias is '{}'.",
alias,
entry.alias
);
}
None => {
anyhow::bail!("No wstemplate entry configured for this project.");
}
},
WstemplateAction::ListEntries => match workspace_state.wstemplate_entry() {
Some(entry) => {
wsb::outln!("{} wstemplate entries:", "1".bold());
wsb::outln!(" {} \u{2192} {}", entry.alias, entry.root.display());
}
None => {
let entry = workspace_state.effective_wstemplate_entry();
wsb::outln!("{} wstemplate entries:", "0".bold());
wsb::outln!(
" effective default: {} \u{2192} {}",
entry.alias,
entry.root.display()
);
}
},
WstemplateAction::List => {
let entry = workspace_state.effective_wstemplate_entry();
let version_info = wsb::state::read_version_from_path(&project_root)?;
let engine = WstemplateEngine::new(
version_info,
workspace_state.project_name.clone(),
entry.alias,
project_root.to_path_buf(),
entry.root,
);
let project_roots = engine.discover_project_roots()?;
let templates = engine.discover_relevant(&project_roots)?;
if templates.is_empty() {
wsb::outln!("No relevant .wstemplate files found.");
} else {
wsb::outln!("{} relevant .wstemplate files:", templates.len());
for path in &templates {
wsb::outln!(" {}", path.display());
}
}
}
WstemplateAction::Vars { alias, remote } => {
let entry = workspace_state.effective_wstemplate_entry();
let engine = WstemplateEngine::new(
wsb::state::read_version_from_path(&project_root)?,
workspace_state.project_name.clone(),
entry.alias.clone(),
project_root.to_path_buf(),
entry.root.clone(),
);
let project_roots = engine.discover_project_roots()?;
let alias = alias.unwrap_or_else(|| entry.alias.clone());
let (root, version) = if alias == entry.alias {
(
project_root.to_path_buf(),
wsb::state::read_version_from_path(&project_root)?,
)
} else {
let root = project_roots.get(&alias).cloned().ok_or_else(|| {
let mut known: Vec<&str> =
project_roots.keys().map(|key| key.as_str()).collect();
known.sort();
anyhow::anyhow!(
"Unknown alias '{}'. Known aliases: [{}]",
alias,
known.join(", ")
)
})?;
let version = wsb::state::read_version_from_path(&root)?;
(root, version)
};
let facts = wsb::state::project_facts::gather(
&wsb::state::project_facts::ProjectIdentity {
alias: &alias,
root: &root,
scan_root: &entry.root,
version: &version,
name: if alias == entry.alias {
workspace_state.project_name.as_deref()
} else {
None
},
},
remote,
)?;
wsb::outln!(
"Template variables for {} ({}):",
alias.bold(),
root.display()
);
for field in wsb::state::project_facts::FIELDS {
let usage = format!("{{{{ projects.{}.{} }}}}", alias, field.name);
match facts.values().get(field.name) {
Some(value) => {
let text = match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
wsb::outln!(" {:<44} {}", usage, text);
}
None => {
let reason = facts
.unavailable()
.get(field.name)
.map(String::as_str)
.unwrap_or("unavailable");
wsb::outln!(" {:<44} {}", usage, format!("— {reason}").dimmed());
}
}
}
wsb::outln!(
"\n{}",
"Also available: {{ datetime.iso | date | time | year | month | day | timestamp }}"
.dimmed()
);
wsb::outln!(
"{}",
"The same fields are available as {{ project.FIELD }} for the project owning a template."
.dimmed()
);
}
WstemplateAction::Render => {
let entry = workspace_state.effective_wstemplate_entry();
let version_info = wsb::state::read_version_from_path(&project_root)?;
let engine = WstemplateEngine::new(
version_info,
workspace_state.project_name.clone(),
entry.alias,
project_root.to_path_buf(),
entry.root,
);
let rendered = engine.render_relevant()?;
if rendered.is_empty() {
wsb::outln!("No templates to render.");
} else {
wsb::outln!("Rendered {} templates:", rendered.len());
for r in &rendered {
wsb::outln!(
" {} \u{2192} {}",
r.source_path.display(),
r.output_path.display()
);
}
}
}
}
Ok(())
}
fn run_scrap_command(paths: Vec<std::path::PathBuf>, command: Option<ScrapCommands>) -> Result<()> {
let mut args = Vec::new();
match command {
Some(ScrapCommands::List { sort }) => {
args.push("list".to_string());
args.push("--sort".to_string());
args.push(sort);
}
Some(ScrapCommands::Clean { days, dry_run }) => {
args.push("clean".to_string());
args.push("--days".to_string());
args.push(days.to_string());
if dry_run {
args.push("--dry-run".to_string());
}
}
Some(ScrapCommands::Purge { force }) => {
args.push("purge".to_string());
if force {
args.push("--force".to_string());
}
}
Some(ScrapCommands::Find { pattern, content }) => {
args.push("find".to_string());
args.push(pattern);
if content {
args.push("--content".to_string());
}
}
Some(ScrapCommands::Archive { output, remove }) => {
args.push("archive".to_string());
if let Some(output_path) = output {
args.push("--output".to_string());
args.push(output_path.to_string_lossy().to_string());
}
if remove {
args.push("--remove".to_string());
}
}
None => {
for path in paths {
args.push(path.to_string_lossy().to_string());
}
}
}
wsb::run_scrap(args)
}
fn run_unscrap_command(
name: Option<String>,
force: bool,
to: Option<std::path::PathBuf>,
) -> Result<()> {
let mut args = Vec::new();
if let Some(item_name) = name {
args.push(item_name);
}
if force {
args.push("--force".to_string());
}
if let Some(target_path) = to {
args.push("--to".to_string());
args.push(target_path.to_string_lossy().to_string());
}
wsb::run_unscrap(args)
}
fn run_ldiff_command(substitute_char: String) -> Result<()> {
let mut chars = substitute_char.chars();
let ch = chars
.next()
.ok_or_else(|| anyhow::anyhow!("Substitute character cannot be empty"))?;
if chars.next().is_some() {
anyhow::bail!(
"Substitute character must be exactly one Unicode character, got: '{}'",
substitute_char
);
}
wsb::ldiff::process_stdin(ch)
}
fn run_compact_command(
input: Option<String>,
output: Option<String>,
token_mode: bool,
) -> Result<()> {
let result = match input {
Some(path) => wsb::compact::compact_file(std::path::Path::new(&path), token_mode)
.with_context(|| format!("Failed to compact file: {}", path))?,
None => {
if std::io::stdin().is_terminal() {
wsb::errln!("Reading from stdin. Pipe input or provide a file path.");
wsb::errln!("Usage: wsb compact [FILE] [-o OUTPUT] [--token-mode]");
anyhow::bail!("No input provided and stdin is a terminal");
}
wsb::compact::compact_stdin(token_mode).with_context(|| "Failed to compact stdin")?
}
};
match output {
Some(path) => {
std::fs::write(&path, &result)
.with_context(|| format!("Failed to write output to: {}", path))?;
}
None => {
wsb::out!("{}", result);
}
}
Ok(())
}
fn run_audit_path_command() -> Result<()> {
let path_var = std::env::var("PATH").context("PATH environment variable not set")?;
let dirs: Vec<&str> = path_var.split(':').collect();
let mut all_bins: std::collections::HashMap<String, Vec<std::path::PathBuf>> =
std::collections::HashMap::new();
for dir in &dirs {
let dir_path = std::path::Path::new(dir);
if let Ok(entries) = std::fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let is_executable = std::fs::metadata(&path)
.map(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
})
.unwrap_or(false);
if is_executable {
all_bins.entry(name.to_string()).or_default().push(path);
}
}
}
}
}
}
let mut shadowed: Vec<(&String, &Vec<std::path::PathBuf>)> = all_bins
.iter()
.filter(|(_, paths)| paths.len() > 1)
.collect();
shadowed.sort_by_key(|(name, _)| (*name).clone());
if shadowed.is_empty() {
wsb::outln!("No shadowed executables found on PATH.");
return Ok(());
}
for (name, paths) in &shadowed {
wsb::outln!("== {} ==", name);
for path in paths.iter() {
wsb::outln!(" {}", path.display());
}
wsb::outln!();
}
wsb::outln!("{} shadowed executable(s) found.", shadowed.len());
Ok(())
}
fn run_ignore_command(line: &str) -> Result<()> {
let current_dir = std::env::current_dir().context("Cannot determine current directory")?;
let entries = std::fs::read_dir(¤t_dir)
.with_context(|| format!("Cannot read directory: {}", current_dir.display()))?;
let mut modified_count = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let gitignore = path.join(".gitignore");
if gitignore.exists() {
let content = std::fs::read_to_string(&gitignore)
.with_context(|| format!("Cannot read {}", gitignore.display()))?;
if !content.lines().any(|l| l == line) {
let mut new_content = content;
if !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str(line);
new_content.push('\n');
std::fs::write(&gitignore, new_content)
.with_context(|| format!("Cannot write {}", gitignore.display()))?;
wsb::outln!("Added '{}' to {}", line, gitignore.display());
modified_count += 1;
}
}
}
}
if modified_count == 0 {
wsb::outln!("No .gitignore files needed updating (line already present or no .gitignore files found).");
} else {
wsb::outln!("Updated {} .gitignore file(s).", modified_count);
}
Ok(())
}
fn run_last_hash_command(file: &str) -> Result<()> {
let output = wsb::git_backend::git_command()
.args([
"log",
"-n",
"1",
"--follow",
"--pretty=format:%H",
"--",
file,
])
.output()
.context("Failed to execute git log")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git log failed: {}", stderr.trim());
}
let hash = String::from_utf8_lossy(&output.stdout);
let hash = hash.trim();
if hash.is_empty() {
anyhow::bail!("No commit found for file: {}", file);
}
wsb::outln!("{}", hash);
Ok(())
}
fn calculate_version(
project_root: &std::path::Path,
cache: &Arc<RunCache>,
) -> Result<VersionInfo> {
let version_info =
wsb::state::state_common::calculate_workspace_version_cached(project_root, cache)?;
info!("Version calculated: {}", version_info.full_version);
Ok(version_info)
}
fn compare_renders(
shared: &[wsb::state::wstemplate::Produced],
alone: &[wsb::state::wstemplate::Produced],
project_root: &Path,
) -> Result<()> {
if shared.len() != alone.len() {
let named = |set: &[wsb::state::wstemplate::Produced]| {
set.iter()
.map(|one| one.output_path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
};
anyhow::bail!(
"--verify: updating {} renders {} file(s) through the run's cache and {} through a \
cache of its own.\n with the run's: {}\n on its own: {}",
project_root.display(),
shared.len(),
alone.len(),
named(shared),
named(alone)
);
}
for (a, b) in shared.iter().zip(alone) {
anyhow::ensure!(
a.output_path == b.output_path,
"--verify: updating {} renders {} through the run's cache where a cache of its own \
renders {}",
project_root.display(),
a.output_path.display(),
b.output_path.display()
);
anyhow::ensure!(
a.content == b.content,
"--verify: {} renders differently through the run's cache than on its own.\n\
This is the cache serving an answer that stopped being true — report it with the \
template at {}.",
a.output_path.display(),
a.source_path.display()
);
}
Ok(())
}
fn calculate_version_without_git(project_root: &std::path::Path) -> Result<VersionInfo> {
let version_path = project_root.join("version.txt");
let version_info = if version_path.exists() {
wsb::state::read_version_from_path(project_root)?
} else {
VersionInfo {
major_version: "v0".to_string(),
minor_version: 0,
patch_version: 0,
full_version: "0.0.0".to_string(),
}
};
info!(
"Version calculated without git state: {}",
version_info.full_version
);
Ok(version_info)
}
fn log_action(message: &str) {
match log_to_file(message) {
Ok(_) => {}
Err(_) => {}
}
}
fn log_to_file(message: &str) -> Result<()> {
info!("{}", message);
Ok(())
}
fn handle_template_command(action: TemplateAction, ctx: &AppContext) -> Result<()> {
match action {
TemplateAction::Add {
name,
template,
output,
description,
} => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let mut template_manager = TemplateManager::new(&workspace_state)?;
let output = output.context("Missing required --output path")?;
let template_content = resolve_template_content(&template)?;
template_manager.add_template(&name, &template_content, &output, description)?;
wsb::outln!("Added template '{}' → {}", name, output);
Ok(())
}
TemplateAction::List => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let template_manager = TemplateManager::new(&workspace_state)?;
let mut templates = template_manager.list_templates();
templates.sort_by(|a, b| a.name.cmp(&b.name));
if templates.is_empty() {
wsb::outln!("No templates configured");
return Ok(());
}
for template in templates {
let status = if template.enabled {
"enabled"
} else {
"disabled"
};
wsb::outln!("{} → {} ({})", template.name, template.output_path, status);
if let Some(description) = &template.description {
wsb::outln!(" {}", description);
}
}
Ok(())
}
TemplateAction::Show { name } => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let template_manager = TemplateManager::new(&workspace_state)?;
let template = template_manager
.get_template(&name)
.ok_or_else(|| anyhow::anyhow!("Template '{}' not found", name))?;
let template_path = workspace_state
.workspace_dir()
.join("templates")
.join(&template.source_path);
let template_content = fs::read_to_string(&template_path).with_context(|| {
format!(
"Failed to read template source: {}",
template_path.display()
)
})?;
wsb::outln!("Template: {}", template.name);
wsb::outln!("Output: {}", template.output_path);
wsb::outln!("Enabled: {}", if template.enabled { "Yes" } else { "No" });
if let Some(description) = &template.description {
wsb::outln!("Description: {}", description);
}
wsb::outln!();
wsb::outln!("{}", template_content);
Ok(())
}
TemplateAction::Enable { name, disable } => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let mut template_manager = TemplateManager::new(&workspace_state)?;
let changed = template_manager.set_template_enabled(&name, !disable)?;
if !changed {
wsb::outln!("Template '{}' not found", name);
return Ok(());
}
if disable {
wsb::outln!("Template '{}' disabled", name);
} else {
wsb::outln!("Template '{}' enabled", name);
}
Ok(())
}
TemplateAction::Remove { name } => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let mut template_manager = TemplateManager::new(&workspace_state)?;
let removed = template_manager.remove_template(&name)?;
if removed {
wsb::outln!("Removed template '{}'", name);
} else {
wsb::outln!("Template '{}' not found", name);
}
Ok(())
}
TemplateAction::Render => {
let project_root = ctx.project_root.clone();
let workspace_state = WorkspaceState::load(&project_root)?;
let template_manager = TemplateManager::new(&workspace_state)?;
let rendered = template_manager.render_all_templates(
&wsb::state::read_version_from_path(&project_root)?,
workspace_state.project_name.as_deref(),
)?;
if rendered.is_empty() {
wsb::outln!("No enabled templates to render");
} else {
wsb::outln!("Rendered {} template(s)", rendered.len());
for output in rendered {
wsb::outln!("{}", output);
}
}
Ok(())
}
TemplateAction::GenerateDocs {
doc_type,
output,
force,
} => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
handle_generate_docs(&doc_type, output.as_deref(), force, ctx).await
})
}
TemplateAction::InitDocs { force } => handle_init_docs(force),
}
}
fn resolve_template_content(template: &str) -> Result<String> {
let template_path = Path::new(template);
if template_path.exists() {
return fs::read_to_string(template_path)
.with_context(|| format!("Failed to read template file: {}", template_path.display()));
}
let looks_like_file_path = !template.contains('\n')
&& !template.contains("{{")
&& !template.contains("{%")
&& (template.contains(std::path::MAIN_SEPARATOR) || template_path.extension().is_some());
if looks_like_file_path {
anyhow::bail!("Template file not found: {}", template);
}
Ok(template.to_string())
}
async fn handle_generate_docs(
doc_type: &str,
output_dir: Option<&str>,
force: bool,
ctx: &AppContext,
) -> Result<()> {
use tera::Tera;
let db = &ctx.db;
let project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| {
anyhow::anyhow!("No project in the database. Run 'wsb init' in the project root.")
})?;
let features = db.feature_repo.list(None, None).await?;
let sessions = db
.work_session_repo
.list(None, None)
.await?
.into_iter()
.filter(|s| s.project_id == project.id)
.collect::<Vec<_>>();
let tasks = db.task_repo.list(None, None).await?;
let total_features = features.len();
let implemented_features = features
.iter()
.filter(|f| {
matches!(
f.state.as_str(),
"implemented_no_tests" | "implemented_failing_tests" | "implemented_passing_tests"
)
})
.count();
let tested_features = features
.iter()
.filter(|f| {
matches!(
f.state.as_str(),
"implemented_passing_tests" | "implemented_failing_tests" | "tests_broken"
)
})
.count();
let implementation_percentage = if total_features > 0 {
(implemented_features * 100) / total_features
} else {
0
};
let test_percentage = if total_features > 0 {
(tested_features * 100) / total_features
} else {
0
};
let mut tera = Tera::new("src/templates/*.tera")?;
tera.register_filter(
"feature_state_emoji",
|value: &tera::Value, _: &HashMap<String, tera::Value>| match value.as_str().unwrap_or("") {
"not_implemented" => Ok(tera::Value::String("❌".to_string())),
"implemented_no_tests" => Ok(tera::Value::String("🟠".to_string())),
"implemented_failing_tests" => Ok(tera::Value::String("🟡".to_string())),
"implemented_passing_tests" => Ok(tera::Value::String("🟢".to_string())),
"tests_broken" => Ok(tera::Value::String("⚠️".to_string())),
"critical_issue" => Ok(tera::Value::String("🔴".to_string())),
_ => Ok(tera::Value::String("❓".to_string())),
},
);
let output_path = output_dir.unwrap_or(".");
match doc_type {
"all" => {
generate_claude_md(
&tera,
&project,
&features,
&sessions,
&tasks,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
generate_features_md(
&tera,
&project,
&features,
total_features,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
generate_progress_md(&tera, &sessions, output_path, force).await?;
generate_status_report(
&project,
&features,
&tasks,
&sessions,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
}
"claude" => {
generate_claude_md(
&tera,
&project,
&features,
&sessions,
&tasks,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
}
"features" => {
generate_features_md(
&tera,
&project,
&features,
total_features,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
}
"progress" => {
generate_progress_md(&tera, &sessions, output_path, force).await?;
}
"status" => {
generate_status_report(
&project,
&features,
&tasks,
&sessions,
implementation_percentage,
test_percentage,
output_path,
force,
)
.await?;
}
_ => {
anyhow::bail!("Unknown documentation type: {}. Use 'claude', 'features', 'progress', 'status', or 'all'", doc_type);
}
}
wsb::outln!(
"✅ Documentation generation complete for type: {}",
doc_type
);
Ok(())
}
async fn generate_claude_md(
tera: &tera::Tera,
project: &wsb::entities::schema_models::Project,
features: &[wsb::entities::schema_models::Feature],
sessions: &[wsb::entities::schema_models::Session],
_tasks: &[wsb::entities::schema_models::Task],
implementation_percentage: usize,
test_percentage: usize,
output_path: &str,
force: bool,
) -> Result<()> {
let mut context = tera::Context::new();
context.insert("project", project);
context.insert("features", features);
context.insert(
"recent_sessions",
&sessions.iter().take(3).collect::<Vec<_>>(),
);
context.insert("total_features", &features.len());
context.insert(
"implemented_features",
&features
.iter()
.filter(|f| {
matches!(
f.state.as_str(),
"implemented_no_tests"
| "implemented_failing_tests"
| "implemented_passing_tests"
)
})
.count(),
);
context.insert(
"tested_features",
&features
.iter()
.filter(|f| matches!(f.state.as_str(), "implemented_passing_tests"))
.count(),
);
context.insert("implementation_percentage", &implementation_percentage);
context.insert("test_percentage", &test_percentage);
context.insert("generated_at", &chrono::Utc::now());
let rendered = tera.render("claude_md.tera", &context)?;
let output_file = std::path::Path::new(output_path).join("CLAUDE.md");
write_doc_file(&output_file, &rendered, force)?;
wsb::outln!("Generated: {}", output_file.display());
Ok(())
}
async fn generate_features_md(
tera: &tera::Tera,
project: &wsb::entities::schema_models::Project,
features: &[wsb::entities::schema_models::Feature],
total_features: usize,
implementation_percentage: usize,
test_percentage: usize,
output_path: &str,
force: bool,
) -> Result<()> {
use std::collections::BTreeMap;
let mut context = tera::Context::new();
context.insert("project", project);
context.insert("features", features);
context.insert("total_features", &total_features);
context.insert("implementation_percentage", &implementation_percentage);
context.insert("test_percentage", &test_percentage);
context.insert("generated_at", &chrono::Utc::now());
let mut features_by_category: BTreeMap<String, Vec<&wsb::entities::schema_models::Feature>> =
BTreeMap::new();
for feature in features {
let category = feature.category.as_deref().unwrap_or("General").to_string();
features_by_category
.entry(category)
.or_insert_with(Vec::new)
.push(feature);
}
context.insert("features_by_category", &features_by_category);
let mut feature_counts = HashMap::new();
for feature in features {
let state_name = format!("{:?}", feature.state);
*feature_counts.entry(state_name).or_insert(0) += 1;
}
context.insert("feature_counts", &feature_counts);
let rendered = tera.render("features_md.tera", &context)?;
let output_file = std::path::Path::new(output_path)
.join("internal")
.join("FEATURES.md");
if let Some(parent) = output_file.parent() {
std::fs::create_dir_all(parent)?;
}
write_doc_file(&output_file, &rendered, force)?;
wsb::outln!("Generated: {}", output_file.display());
Ok(())
}
async fn generate_progress_md(
_tera: &tera::Tera,
sessions: &[wsb::entities::schema_models::Session],
output_path: &str,
force: bool,
) -> Result<()> {
let mut content = String::new();
content.push_str("# Progress Tracking - Database Generated\n\n");
content.push_str(&format!(
"**Generated**: {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
content.push_str("**Source**: Database-driven documentation generation\n\n");
content.push_str("## Recent Sessions\n\n");
for session in sessions.iter().take(5) {
content.push_str(&format!("### {}\n", &session.title));
content.push_str(&format!("**Status**: {}\n", &session.status));
content.push_str(&format!("**Date**: {}\n", &session.date));
if let Some(start_time) = &session.start_time {
content.push_str(&format!("**Started**: {} {}\n", &session.date, start_time));
}
if let Some(end_time) = &session.end_time {
content.push_str(&format!("**Ended**: {} {}\n", &session.date, end_time));
}
content.push_str("\n");
}
content.push_str("---\n\n*Generated from database entities*\n");
let output_file = std::path::Path::new(output_path)
.join("internal")
.join("PROGRESS_TRACKING.md");
if let Some(parent) = output_file.parent() {
std::fs::create_dir_all(parent)?;
}
write_doc_file(&output_file, &content, force)?;
wsb::outln!("Generated: {}", output_file.display());
Ok(())
}
async fn generate_status_report(
project: &wsb::entities::schema_models::Project,
features: &[wsb::entities::schema_models::Feature],
tasks: &[wsb::entities::schema_models::Task],
sessions: &[wsb::entities::schema_models::Session],
implementation_percentage: usize,
test_percentage: usize,
output_path: &str,
force: bool,
) -> Result<()> {
let mut content = String::new();
content.push_str("# Project Status Report\n\n");
content.push_str(&format!(
"**Generated**: {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
content.push_str(&format!("**Project**: {}\n", project.name));
content.push_str(&format!(
"**Phase**: {}\n\n",
project.current_phase.as_deref().unwrap_or("Unknown")
));
content.push_str("## Implementation Status\n\n");
content.push_str(&format!("- **Total Features**: {}\n", features.len()));
content.push_str(&format!(
"- **Implementation Score**: {}%\n",
implementation_percentage
));
content.push_str(&format!("- **Test Coverage**: {}%\n", test_percentage));
content.push_str(&format!(
"- **Active Tasks**: {}\n",
tasks.iter().filter(|t| t.status == "in_progress").count()
));
content.push_str(&format!(
"- **Completed Tasks**: {}\n",
tasks.iter().filter(|t| t.status == "completed").count()
));
content.push_str(&format!("- **Total Sessions**: {}\n\n", sessions.len()));
content.push_str("## Feature State Distribution\n\n");
let mut state_counts = std::collections::HashMap::new();
for feature in features {
*state_counts.entry(&feature.state).or_insert(0) += 1;
}
for (state, count) in state_counts {
let emoji = match state.as_str() {
"not_implemented" => "❌",
"implemented_no_tests" => "🟠",
"implemented_failing_tests" => "🟡",
"implemented_passing_tests" => "🟢",
"tests_broken" => "⚠️",
"critical_issue" => "🔴",
_ => "❓",
};
content.push_str(&format!("- {} {}: {}\n", emoji, state, count));
}
content.push_str("\n---\n\n*Generated from database entities*\n");
let output_file = std::path::Path::new(output_path).join("PROJECT_STATUS.md");
write_doc_file(&output_file, &content, force)?;
wsb::outln!("Generated: {}", output_file.display());
Ok(())
}
fn handle_init_docs(_force: bool) -> Result<()> {
info!("Documentation template initialization not yet implemented");
Ok(())
}
fn write_doc_file(file_path: &Path, content: &str, force: bool) -> Result<()> {
if file_path.exists() && !force {
wsb::errln!(
"{}: File {} already exists. Use --force to overwrite.",
"Error".red(),
file_path.display()
);
return Ok(());
}
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(file_path, content)?;
Ok(())
}
fn ensure_git_repository_at(path: &Path) -> Result<()> {
if is_workspace_git_repository(path) {
return Ok(());
}
wsb::git_backend::init_repository(path)?;
wsb::outln!("Initialized Git repository at {}", path.display());
Ok(())
}
fn setup_new_project(
first_task: Option<String>,
name: Option<String>,
ctx: &AppContext,
) -> Result<()> {
let project_root = std::env::current_dir().context("Failed to determine current directory")?;
let state = setup_workspace_at(&project_root, name.as_deref(), ctx)?;
print_workspace_setup_complete(&state, first_task.as_deref());
Ok(())
}
fn setup_workspace_at(
project_root: &Path,
name: Option<&str>,
ctx: &AppContext,
) -> Result<WorkspaceState> {
wsb::outln!(
"{}",
format!("Setting up wsb workspace at {}...", project_root.display())
.bold()
.green()
);
ensure_git_repository_at(project_root)?;
let mut state = WorkspaceState::initialize(project_root, name)?;
state.set_wstemplate_entry(default_wstemplate_entry(project_root)?);
state.save(project_root)?;
if let Some(major) = wsb::state::read_major_version_from_file(&project_root) {
ctx.seed_major_version(major)?;
}
wsb::workspace::exclude_state_dir(project_root)?;
let git_dir = workspace_git_dir(project_root)?;
let hooks_dir = git_dir.join("hooks");
if hooks_dir.is_dir() {
let hook_path = hooks_dir.join("pre-commit");
let hook_content = PRE_COMMIT_HOOK;
let existing = match std::fs::read_to_string(&hook_path) {
Ok(existing) => Some(existing),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e).with_context(|| format!("reading {}", hook_path.display()))
}
};
let needs_write = match &existing {
None => true,
Some(existing) if existing == hook_content => false,
Some(existing) if is_wsb_hook(existing) => true,
Some(existing) => {
if let Some(problem) = pre_commit_hook_problem(existing) {
wsb::errln!(
" {} left the pre-commit hook at {} as it is: it {}",
"!".yellow(),
hook_path.display(),
problem
);
}
false
}
};
if needs_write {
std::fs::write(&hook_path, hook_content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
wsb::outln!(
" {} installed pre-commit hook: {}",
"✓".green(),
hook_path.display()
);
}
}
Ok(state)
}
fn default_wstemplate_entry(project_root: &Path) -> Result<WstemplateEntry> {
if let Some((_immediate, submodule_name)) = find_parent_repo_submodule(project_root)? {
return Ok(WstemplateEntry {
root: outermost_superproject(project_root)?
.expect("a project with a parent repository has an outermost one"),
alias: WstemplateEngine::derive_alias(Path::new(&submodule_name)),
});
}
Ok(WstemplateEntry {
root: project_root.to_path_buf(),
alias: WstemplateEngine::derive_alias(project_root),
})
}
fn outermost_superproject(project_root: &Path) -> Result<Option<PathBuf>> {
let mut here = project_root.to_path_buf();
let mut outermost = None;
while let Some((parent, _)) = find_parent_repo_submodule(&here)? {
outermost = Some(parent.clone());
here = parent;
}
Ok(outermost)
}
fn find_parent_repo_submodule(project_root: &Path) -> Result<Option<(PathBuf, String)>> {
let mut parent = project_root.parent();
while let Some(candidate) = parent {
let gitmodules = match GitModules::parse(candidate) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => {
parent = candidate.parent();
continue;
}
Err(error) => {
return Err(anyhow::anyhow!(
"Failed to parse parent .gitmodules at {}: {}",
candidate.display(),
error
));
}
};
let relative_path = project_root.strip_prefix(candidate).with_context(|| {
format!(
"Failed to compute submodule path for {} inside {}",
project_root.display(),
candidate.display()
)
})?;
if let Some(submodule) = gitmodules
.submodules
.into_iter()
.find(|submodule| submodule.path == relative_path)
{
let directory = relative_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if submodule.name != directory {
return Err(anyhow::anyhow!(
"submodule at {} is recorded in {} as '{}' but its directory is '{}'.\n\
The alias a template names is derived from that name, so the two have to \
agree — fix the name in .gitmodules or move the directory.",
relative_path.display(),
candidate.join(".gitmodules").display(),
submodule.name,
directory
));
}
return Ok(Some((candidate.to_path_buf(), submodule.name)));
}
parent = candidate.parent();
}
Ok(None)
}
fn print_workspace_setup_complete(state: &WorkspaceState, first_task: Option<&str>) {
wsb::outln!(
"{}",
format!(
"Workspace '{}' setup completed successfully!",
state.project_name.as_deref().unwrap_or("unknown")
)
.bold()
.green()
);
if let Some(task) = first_task {
wsb::outln!("Ready to start with: {}", task.bold());
}
}
fn is_workspace_git_repository(path: &Path) -> bool {
let git_path = path.join(".git");
git_path.is_dir() || git_path.is_file()
}
fn workspace_git_dir(project_root: &Path) -> Result<PathBuf> {
let git_path = project_root.join(".git");
if git_path.is_dir() {
return Ok(git_path);
}
if git_path.is_file() {
let content = std::fs::read_to_string(&git_path)
.with_context(|| format!("Failed to read {}", git_path.display()))?;
let git_dir = content
.trim()
.strip_prefix("gitdir:")
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Invalid gitdir file at {}", git_path.display()))?;
let git_dir = PathBuf::from(git_dir);
return Ok(if git_dir.is_absolute() {
git_dir
} else {
project_root.join(git_dir)
});
}
anyhow::bail!(
"No .git directory or gitdir file at {}",
project_root.display()
)
}
fn init_nested_workspaces(project_root: &Path) -> Result<()> {
let outcome = SubmoduleOps::init_tree(project_root, true, |node| {
wsb::outln!(
"{}",
format!("Initializing git submodule '{}'", node.label())
.bold()
.green()
);
})
.map_err(|error| {
anyhow::anyhow!(
"Failed to walk submodules at {}: {}",
project_root.display(),
error
)
})?;
if let Some((name, error)) = outcome.failures.into_iter().next() {
anyhow::bail!("Failed to initialize submodule '{}': {}", name, error);
}
let tree = SubmoduleTree::discover_allow_empty(project_root).map_err(|error| {
anyhow::anyhow!(
"Failed to read submodules at {}: {}",
project_root.display(),
error
)
})?;
for node in tree.top_down() {
if !node.initialized {
anyhow::bail!(
"Submodule '{}' is still not checked out after initialization at {}",
node.label(),
node.abs_path.display()
);
}
if !node.abs_path.is_dir() {
anyhow::bail!(
"Submodule '{}' path is not a directory: {}",
node.label(),
node.abs_path.display()
);
}
if !is_workspace_git_repository(&node.abs_path) {
anyhow::bail!(
"Submodule '{}' at {} is not a usable git repository",
node.label(),
node.abs_path.display()
);
}
let ctx = AppContext::bootstrap_for_init(&node.abs_path)?;
let state = setup_workspace_at(&node.abs_path, None, &ctx)?;
print_workspace_setup_complete(&state, None);
}
Ok(())
}
fn read_script_from_stdin() -> Result<String> {
use std::io::Read;
let mut script = String::new();
std::io::stdin()
.read_to_string(&mut script)
.context("Failed to read script from stdin")?;
Ok(script)
}
fn read_script_interactively() -> Result<String> {
use std::io::Read;
wsb::errln!("Enter shell script (Ctrl-D when done):");
let mut script = String::new();
std::io::stdin()
.read_to_string(&mut script)
.context("Failed to read script interactively")?;
Ok(script)
}
fn run_each_command(
project_root: &Path,
file: Option<std::path::PathBuf>,
quiet: bool,
) -> Result<()> {
use colored::Colorize;
use wsb::fleet::config::gitmodules::GitModules;
let script: String = match file {
Some(ref path) => std::fs::read_to_string(path)
.with_context(|| format!("Failed to read script file: {}", path.display()))?,
None => {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
read_script_interactively()?
} else {
read_script_from_stdin()?
}
}
};
let script = script.trim();
if script.is_empty() {
anyhow::bail!("Script is empty — nothing to run");
}
let modules = match GitModules::parse(project_root) {
Ok(m) => m,
Err(wsb::fleet::error::SuperGitError::NoSubmodules) => {
anyhow::bail!(
"No submodules found in {} — 'wsb each' requires a fleet root with .gitmodules",
project_root.display()
);
}
Err(e) => return Err(anyhow::anyhow!(".gitmodules parse error: {:#}", e)),
};
let total = modules.submodules.len() as u64;
let pb = make_progress_bar(quiet, total);
for sub in &modules.submodules {
let sub_path = project_root.join(&sub.path);
if !sub_path.is_dir() {
pb.finish_and_clear();
anyhow::bail!(
"Submodule '{}': path '{}' does not exist — run 'git submodule update --init'",
sub.name,
sub.path.display()
);
}
pb.set_message(sub.name.clone());
let status = wsb::git_backend::command_without_repo_scope("sh")
.arg("-c")
.arg(script)
.current_dir(&sub_path)
.env("WSB_SUBMODULE_PATH", &sub_path)
.env("WSB_SUBMODULE_NAME", &sub.name)
.env("WSB_SUPERPROJECT_ROOT", project_root)
.status()
.with_context(|| format!("Failed to launch sh for submodule '{}'", sub.name))?;
if !status.success() {
pb.finish_and_clear();
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
anyhow::bail!("Script failed in submodule '{}' (exit {})", sub.name, code);
}
pb.inc(1);
}
pb.finish_and_clear();
if !quiet {
wsb::outln!(
"{} ran in {}/{} submodule(s)",
"✓".green().bold(),
total,
total
);
}
Ok(())
}
fn run_check_command(cwd: &Path, no_network: bool, quiet: bool) -> Result<()> {
use colored::Colorize;
use wsb::fleet::config::gitmodules::GitModules;
use wsb::workspace_state::WorkspaceState;
#[derive(Clone)]
struct CheckItem {
category: &'static str,
text: String,
}
let mut issues: Vec<CheckItem> = Vec::new();
let mut warnings: Vec<CheckItem> = Vec::new();
let mut ok_items: Vec<String> = Vec::new();
let pb = make_spinner(quiet, "checking workspace…");
macro_rules! ok {
($msg:expr) => {{
ok_items.push(format!(" {} {}", "✓".green(), $msg))
}};
}
macro_rules! issue {
($cat:literal, $msg:expr) => {{
issues.push(CheckItem {
category: $cat,
text: format!(" {} {}", "✗".red().bold(), $msg),
})
}};
($msg:expr) => {{
issues.push(CheckItem {
category: "General",
text: format!(" {} {}", "✗".red().bold(), $msg),
})
}};
}
macro_rules! warn_item {
($cat:literal, $msg:expr) => {{
warnings.push(CheckItem {
category: $cat,
text: format!(" {} {}", "⚠".yellow(), $msg),
})
}};
($msg:expr) => {{
warnings.push(CheckItem {
category: "General",
text: format!(" {} {}", "⚠".yellow(), $msg),
})
}};
}
pb.set_message("resolving workspace roots…");
let project_root = wsb::workspace::find_project_root(cwd);
let super_root = wsb::workspace::find_super_root(cwd);
let project_root = match project_root {
Some(ref p) => {
ok!(format!("project root: {}", p.display()));
p.clone()
}
None => {
issue!(
"Workspace",
format!(
"no project root found walking up from {} — run 'wsb init' to create one",
cwd.display()
)
);
pb.finish_and_clear();
wsb::outln!();
for i in &issues {
wsb::outln!("{}", i.text);
}
std::process::exit(wsb::output::finish(1));
}
};
match &super_root {
Some(sr) if sr == &project_root => {
ok!("super root: same as project root (no submodule nesting)");
}
Some(sr) => {
ok!(format!("super root: {}", sr.display()));
}
None => {
issue!(
"Workspace",
"could not determine super root — not inside any git repository"
);
}
}
let wsb_dir = project_root.join(".wsb");
if wsb_dir.is_dir() {
ok!(format!(".wsb/ directory exists at {}", wsb_dir.display()));
} else {
issue!(
"Workspace",
format!(
".wsb/ directory missing at {} — run 'wsb init'",
wsb_dir.display()
)
);
}
let state_json_path = wsb_dir.join("state.json");
if !state_json_path.exists() {
issue!(
"State",
format!(
".wsb/state.json missing at {} — run 'wsb init'",
state_json_path.display()
)
);
} else {
ok!(format!(
".wsb/state.json exists at {}",
state_json_path.display()
));
match std::fs::read_to_string(&state_json_path) {
Err(e) => issue!("State", format!(".wsb/state.json is unreadable: {}", e)),
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => issue!("State", format!(".wsb/state.json is not valid JSON: {}", e)),
Ok(_) => {
match WorkspaceState::load(&project_root) {
Ok(state) => {
ok!(format!(
"state.json valid — project_name: {}",
state.project_name.as_deref().unwrap_or("<unset>")
));
if let Some(entry) = state.wstemplate_entry() {
ok!(format!(
"wstemplate alias: '{}', scan root: {}",
entry.alias,
entry.root.display()
));
if !entry.root.is_dir() {
issue!(
"State",
format!(
"wstemplate scan root does not exist: {}",
entry.root.display()
)
);
}
} else {
ok!("no explicit wstemplate entry (alias will be derived from directory name)");
}
}
Err(e) => issue!("State", format!("state.json failed to load: {:#}", e)),
}
}
},
}
}
for subdir in &["templates"] {
let path = wsb_dir.join(subdir);
if path.is_dir() {
ok!(format!(".wsb/{subdir}/ exists"));
} else {
issue!(
"Workspace",
format!(".wsb/{subdir}/ missing — expected to be created by 'wsb init'")
);
}
}
let version_txt_path = project_root.join("version.txt");
if version_txt_path.exists() {
match std::fs::read_to_string(&version_txt_path) {
Err(e) => issue!("Version", format!("version.txt unreadable: {}", e)),
Ok(content) => {
let trimmed = content.trim();
let first_segment = trimmed
.split('.')
.next()
.unwrap_or("")
.trim_start_matches('v');
match first_segment.parse::<u32>() {
Ok(major) => {
ok!(format!(
"version.txt exists — recorded version: {}, major: {}",
trimmed, major
));
}
Err(_) => {
issue!("Version", format!(
"version.txt exists but first segment '{}' is not a valid major version number (content: '{}')",
first_segment, trimmed
));
}
}
}
}
} else {
warn_item!("Version", format!(
"version.txt not found at {} — major version will default to 0 if wsb.db has no project row",
version_txt_path.display()
));
}
let db_root = super_root.as_deref().unwrap_or(&project_root);
let db_path = db_root.join(".wsb").join("wsb.db");
if db_path.exists() {
ok!(format!("wsb.db at super root: {}", db_path.display()));
if db_root != &project_root {
let local_db = project_root.join(".wsb").join("wsb.db");
if local_db.exists() {
issue!("Database", format!(
"wsb.db found at submodule level {} — only one wsb.db should exist at the super root {}",
local_db.display(),
db_path.display()
));
}
}
} else {
issue!("Database", format!(
"wsb.db not found at super root {} — will be created on first 'wsb serve' or database command",
db_path.display()
));
}
{
let build_root = wsb::state::stamp_config::StampConfig::load(db_root)
.ok()
.flatten()
.and_then(|config| config.build_root)
.map(|relative| db_root.join(relative));
let disposable = [Some(wsb::logs::root(db_root)), build_root];
for path in disposable.into_iter().flatten().filter(|path| path.is_dir()) {
match wsb::platform::excluded_from_backups(&path) {
Ok(true) => {}
Ok(false) => warn_item!("Backups", format!(
"{} is disposable and backed up — `tmutil addexclusion {}`",
path.display(),
path.display()
)),
Err(error) => warn_item!("Backups", error),
}
}
}
let exclude_path = project_root.join(".git").join("info").join("exclude");
if !exclude_path.exists() {
issue!(
"Git",
format!(
".git/info/exclude missing at {} — .wsb/ may be accidentally tracked by git",
exclude_path.display()
)
);
} else {
match std::fs::read_to_string(&exclude_path) {
Err(e) => issue!("Git", format!(".git/info/exclude unreadable: {}", e)),
Ok(content) => {
if wsb::workspace::excludes_state(&content) {
ok!(".git/info/exclude hides .wsb/ state and shows its declarations");
} else {
issue!("Git",
".git/info/exclude does not hide .wsb/ state as wsb writes it — state may be committed, or declarations hidden; run 'wsb init' to fix"
);
}
}
}
}
pb.set_message("checking declared local paths…");
let broken_paths = wsb::local_paths::check(&project_root);
if broken_paths.is_empty() {
ok!("every declared local path resolves (cargo, swift, npm, lockfiles)");
} else {
for broken in &broken_paths {
issue!(
"Paths",
format!(
"{}: {} '{}' points at {}, which does not exist",
broken.manifest, broken.kind, broken.declared, broken.resolves_to
)
);
}
}
pb.set_message("checking for committed home paths…");
let home_paths = wsb::local_paths::home_rooted(&project_root);
if home_paths.is_empty() {
ok!("no committed file names a path inside somebody's home directory");
} else {
for found in &home_paths {
issue!(
"Paths",
format!(
"{}:{} names {}, which is one machine's layout — make it relative to the project",
found.manifest, found.line, found.path
)
);
}
}
pb.set_message("checking for source hidden from git…");
let hidden = wsb::local_paths::hidden_source(&project_root);
if hidden.is_empty() {
ok!("no source file is hidden from git by an ignore rule");
} else {
for file in &hidden {
issue!(
"Paths",
format!(
"{} is a source file git is ignoring ({}) — anchor the pattern with a leading slash",
file.path, file.rule
)
);
}
}
pb.set_message("checking pre-commit hook…");
let hook_path = project_root.join(".git").join("hooks").join("pre-commit");
if !hook_path.exists() {
issue!("Git", format!(
"pre-commit hook missing at {} — run 'wsb init' to install it (runs 'wsb update --quiet --git-add' on every commit)",
hook_path.display()
));
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&hook_path)?.permissions();
if perms.mode() & 0o111 == 0 {
issue!(
"Git",
format!(
"pre-commit hook at {} is not executable — run: chmod +x {}",
hook_path.display(),
hook_path.display()
)
);
} else {
ok!(format!(
"pre-commit hook is executable: {}",
hook_path.display()
));
}
}
match std::fs::read_to_string(&hook_path) {
Err(e) => issue!("Git", format!("pre-commit hook unreadable: {}", e)),
Ok(hook_content) => match pre_commit_hook_problem(&hook_content) {
Some(problem) => issue!(
"Git",
format!("pre-commit hook at {} {problem}", hook_path.display())
),
None => {
ok!("pre-commit hook invokes '{PRE_COMMIT_MARK}' with the driving wsb")
}
},
}
}
let gitmodules_path = project_root.join(".gitmodules");
let is_fleet_root = gitmodules_path.is_file();
if !is_fleet_root {
ok!("no .gitmodules — single-repo workspace (not a fleet root)");
} else {
ok!(format!(
".gitmodules found at {}",
gitmodules_path.display()
));
match GitModules::parse(&project_root) {
Err(e) => {
issue!("Fleet", format!(".gitmodules parse error: {:#}", e));
}
Ok(modules) => {
ok!(format!(
"{} submodule(s) declared in .gitmodules",
modules.submodules.len()
));
let submodule_status_output = wsb::git_backend::git_command()
.args(["submodule", "status"])
.current_dir(&project_root)
.output()
.ok();
let mut all_aliases: Vec<(String, String)> = Vec::new();
if let Ok(root_state) = WorkspaceState::load(&project_root) {
if let Some(entry) = root_state.wstemplate_entry() {
all_aliases.push((entry.alias.clone(), project_root.display().to_string()));
}
}
for sub in &modules.submodules {
let sub_path = project_root.join(&sub.path);
let display = sub.path.display().to_string();
if no_network {
warn_item!(
"Fleet",
format!(
"submodule '{}': skipping remote URL check (--no-network) — url: {}",
sub.name, sub.url
)
);
} else {
let ls_remote = wsb::git_backend::git_command()
.args(["ls-remote", "--exit-code", "--heads", &sub.url])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output();
match ls_remote {
Ok(out) if out.status.success() => {
ok!(format!(
"submodule '{}': remote url reachable — {}",
sub.name, sub.url
));
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
issue!(
"Fleet",
format!(
"submodule '{}': remote url unreachable — {} ({})",
sub.name,
sub.url,
stderr.trim()
)
);
}
Err(e) => {
issue!("Fleet", format!(
"submodule '{}': could not run 'git ls-remote' to check url {}: {}",
sub.name, sub.url, e
));
}
}
}
if !sub_path.is_dir() {
issue!("Fleet", format!(
"submodule '{}': path '{}' does not exist on disk — run 'git submodule update --init'",
sub.name, display
));
continue;
}
ok!(format!(
"submodule '{}': path exists at {}",
sub.name,
sub_path.display()
));
let sub_git = sub_path.join(".git");
if !sub_git.exists() {
issue!("Fleet", format!(
"submodule '{}': no .git entry — submodule not initialized (run 'git submodule update --init')",
sub.name
));
} else {
ok!(format!(
"submodule '{}': .git entry present (initialized)",
sub.name
));
}
if let Some(ref branch) = sub.branch {
if branch == "." {
ok!(format!(
"submodule '{}': branch tracks superproject HEAD (branch = .)",
sub.name
));
} else if no_network {
warn_item!("Fleet", format!(
"submodule '{}': skipping branch '{}' existence check (--no-network)",
sub.name, branch
));
} else {
let branch_check = wsb::git_backend::git_command()
.args(["ls-remote", "--exit-code", "--heads", &sub.url, branch])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output();
match branch_check {
Ok(out) if out.status.success() => {
ok!(format!(
"submodule '{}': declared branch '{}' exists on remote",
sub.name, branch
));
}
Ok(_) => {
issue!("Fleet", format!(
"submodule '{}': declared branch '{}' does not exist on remote {}",
sub.name, branch, sub.url
));
}
Err(e) => {
issue!(
"Fleet",
format!(
"submodule '{}': could not check branch '{}' on remote: {}",
sub.name, branch, e
)
);
}
}
}
} else {
warn_item!("Fleet", format!(
"submodule '{}': no branch declared in .gitmodules — submodule will track a detached commit",
sub.name
));
}
if let Some(ref status_out) = submodule_status_output {
if status_out.status.success() {
let status_text = String::from_utf8_lossy(&status_out.stdout);
let sub_path_str = sub.path.to_string_lossy();
let line = status_text.lines().find(|l| {
l.trim_start_matches(['-', '+', 'U', ' '])
.contains(sub_path_str.as_ref())
});
match line {
None => {
issue!("Fleet", format!(
"submodule '{}': not found in 'git submodule status' output — may not be registered in git index",
sub.name
));
}
Some(l) => {
let prefix = l.chars().next().unwrap_or(' ');
match prefix {
'-' => issue!("Fleet", format!(
"submodule '{}': not initialized (git submodule status shows '-') — run 'git submodule update --init'",
sub.name
)),
'+' => issue!("Fleet", format!(
"submodule '{}': checked out commit differs from superproject pointer (git submodule status shows '+') — run 'git submodule update'",
sub.name
)),
'U' => issue!("Fleet", format!(
"submodule '{}': merge conflict in submodule (git submodule status shows 'U')",
sub.name
)),
_ => ok!(format!(
"submodule '{}': commit pointer matches superproject (clean)",
sub.name
)),
}
}
}
} else {
let stderr = String::from_utf8_lossy(&status_out.stderr);
warn_item!(
"Fleet",
format!("could not run 'git submodule status': {}", stderr.trim())
);
}
}
let sub_wsb_dir = sub_path.join(".wsb");
if !sub_wsb_dir.is_dir() {
issue!("Fleet", format!(
"submodule '{}': .wsb/ missing at {} — run 'wsb init' inside that submodule",
sub.name, sub_wsb_dir.display()
));
} else {
ok!(format!("submodule '{}': .wsb/ exists", sub.name));
let sub_db = sub_wsb_dir.join("wsb.db");
if sub_db.exists() {
issue!("Database", format!(
"submodule '{}': wsb.db found at {} — only one wsb.db should exist at the super root",
sub.name, sub_db.display()
));
}
}
let sub_state_path = sub_wsb_dir.join("state.json");
if !sub_state_path.exists() {
issue!(
"State",
format!(
"submodule '{}': .wsb/state.json missing — run 'wsb init' inside {}",
sub.name, sub_path.display()
)
);
} else {
ok!(format!("submodule '{}': .wsb/state.json exists", sub.name));
match WorkspaceState::load(&sub_path) {
Err(e) => issue!(
"State",
format!(
"submodule '{}': state.json failed to load: {:#}",
sub.name, e
)
),
Ok(state) => {
ok!(format!(
"submodule '{}': state.json valid — project_name: {}",
sub.name,
state.project_name.as_deref().unwrap_or("<unset>")
));
if let Some(entry) = state.wstemplate_entry() {
all_aliases.push((
entry.alias.clone(),
sub_path.display().to_string(),
));
}
}
}
}
let sub_version_txt = sub_path.join("version.txt");
if sub_version_txt.exists() {
match std::fs::read_to_string(&sub_version_txt) {
Err(e) => issue!(
"Version",
format!("submodule '{}': version.txt unreadable: {}", sub.name, e)
),
Ok(content) => {
let trimmed = content.trim();
let first_seg = trimmed
.split('.')
.next()
.unwrap_or("")
.trim_start_matches('v');
match first_seg.parse::<u32>() {
Ok(major) => ok!(format!(
"submodule '{}': version.txt — recorded version: {}, major: {}",
sub.name, trimmed, major
)),
Err(_) => issue!("Version", format!(
"submodule '{}': version.txt first segment '{}' is not a valid major version (content: '{}')",
sub.name, first_seg, trimmed
)),
}
}
}
} else {
warn_item!("Version", format!(
"submodule '{}': no version.txt — major version defaults to 0 if not already in wsb.db",
sub.name
));
}
let sub_git_dir = workspace_git_dir(&sub_path)
.unwrap_or_else(|_| sub_path.join(".git"));
let sub_exclude = sub_git_dir.join("info").join("exclude");
if sub_exclude.exists() {
match std::fs::read_to_string(&sub_exclude) {
Ok(c) if wsb::workspace::excludes_state(&c) => {
ok!(format!(
"submodule '{}': .git/info/exclude hides .wsb/ state",
sub.name
));
}
Ok(_) => {
issue!("Git", format!(
"submodule '{}': .git/info/exclude does not hide .wsb/ state as wsb writes it — run 'wsb init' there to fix",
sub.name
));
}
Err(e) => {
issue!(
"Git",
format!(
"submodule '{}': .git/info/exclude unreadable: {}",
sub.name, e
)
);
}
}
} else {
issue!("Git", format!(
"submodule '{}': .git/info/exclude missing — .wsb/ may be accidentally tracked",
sub.name
));
}
let sub_hook = sub_git_dir.join("hooks").join("pre-commit");
if !sub_hook.exists() {
issue!("Git", format!(
"submodule '{}': pre-commit hook missing — run 'wsb init' inside {} to install it",
sub.name, sub_path.display()
));
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&sub_hook) {
if meta.permissions().mode() & 0o111 == 0 {
issue!(
"Git",
format!(
"submodule '{}': pre-commit hook is not executable",
sub.name
)
);
}
}
}
match std::fs::read_to_string(&sub_hook) {
Err(e) => issue!(
"Git",
format!(
"submodule '{}': pre-commit hook unreadable: {}",
sub.name, e
)
),
Ok(content) => {
if let Some(problem) = pre_commit_hook_problem(&content) {
issue!("Git", format!(
"submodule '{}': pre-commit hook {problem}",
sub.name
));
} else {
ok!(format!(
"submodule '{}': pre-commit hook correct",
sub.name
));
}
}
}
}
}
let mut seen_aliases: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (alias, source) in &all_aliases {
seen_aliases
.entry(alias.clone())
.or_default()
.push(source.clone());
}
for (alias, sources) in &seen_aliases {
if sources.len() > 1 {
issue!(
"Fleet",
format!(
"duplicate wstemplate alias '{}' in {} projects: {}",
alias,
sources.len(),
sources.join(", ")
)
);
} else {
ok!(format!("wstemplate alias '{}' is unique", alias));
}
}
}
}
}
pb.finish_and_clear();
if !quiet {
wsb::outln!();
for item in &ok_items {
wsb::outln!("{}", item);
}
}
if !warnings.is_empty() {
let mut by_cat: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for w in &warnings {
by_cat.entry(w.category).or_default().push(&w.text);
}
wsb::outln!();
wsb::outln!("{} {} warning(s):", "⚠".yellow(), warnings.len());
for (cat, items) in &by_cat {
wsb::outln!(" [{}]", cat.yellow());
for item in items {
wsb::outln!("{}", item);
}
}
}
if !issues.is_empty() {
let mut by_cat: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for iss in &issues {
by_cat.entry(iss.category).or_default().push(&iss.text);
}
wsb::outln!();
wsb::outln!("{} {} issue(s) found:", "✗".red().bold(), issues.len());
for (cat, items) in &by_cat {
wsb::outln!(" [{}]", cat.red().bold());
for item in items {
wsb::outln!("{}", item);
}
}
wsb::outln!();
std::process::exit(wsb::output::finish(1));
} else {
wsb::outln!();
wsb::outln!("{}", "All checks passed.".green().bold());
}
Ok(())
}
fn add_files_to_git(files: &[String], project_root: &Path) -> Result<Vec<String>> {
let repo = wsb::git_backend::discover_repo(project_root)?;
let repo_root = repo
.workdir()
.context("Bare repositories are not supported for staging")?;
let mut files_to_stage = Vec::new();
for file in files {
let file_path = std::path::Path::new(file);
let absolute = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
project_root.join(file_path)
};
if !absolute.exists() {
continue;
}
let relative_path = match absolute.strip_prefix(repo_root) {
Ok(rel) => rel.to_string_lossy().to_string(),
Err(_) => continue,
};
if let Some(submodule) = wsb::git_backend::submodule_containing(&repo, &relative_path)? {
debug!(
"not staging '{}': it lives in submodule '{}', which owns its own index",
relative_path, submodule
);
continue;
}
files_to_stage.push(relative_path);
}
let added_files = wsb::git_backend::stage_paths(&repo, &files_to_stage)?;
for file in &added_files {
log_action(&format!("Added file to git: {}", file));
}
Ok(added_files)
}
fn install_shell_completions(shell: Option<Shell>) -> Result<()> {
let shell = match shell {
Some(shell) => shell,
None => detect_shell()?,
};
generate_completion_file(shell)?;
let completion_dir = get_completion_dir(shell)?;
let completion_file = get_completion_file_path(shell, &completion_dir)?;
print_activation_hint(shell, &completion_file);
Ok(())
}
fn detect_shell() -> Result<Shell> {
if let Ok(shell) = env::var("SHELL") {
if shell.contains("bash") {
return Ok(Shell::Bash);
} else if shell.contains("zsh") {
return Ok(Shell::Zsh);
} else if shell.contains("fish") {
return Ok(Shell::Fish);
}
}
if env::var("ZSH_VERSION").is_ok() {
return Ok(Shell::Zsh);
} else if env::var("BASH_VERSION").is_ok() {
return Ok(Shell::Bash);
} else if env::var("FISH_VERSION").is_ok() {
return Ok(Shell::Fish);
}
Ok(Shell::Bash)
}
fn generate_completion_file(shell: Shell) -> Result<()> {
let mut completion_script = Vec::new();
{
let mut app = Args::command();
let name = app.get_name().to_string();
generate(shell, &mut app, name, &mut completion_script);
}
let completion_content = String::from_utf8(completion_script)
.context("Failed to convert completion script to string")?;
let completion_dir = get_completion_dir(shell)?;
fs::create_dir_all(&completion_dir).context("Failed to create completion directory")?;
let completion_file = get_completion_file_path(shell, &completion_dir)?;
fs::write(&completion_file, &completion_content)
.context("Failed to write completion script")?;
Ok(())
}
fn get_completion_dir(shell: Shell) -> Result<PathBuf> {
let data_home = env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = env::var("HOME").unwrap_or_default();
format!("{}/.local/share", home)
});
let dir = match shell {
Shell::Zsh => format!("{}/zsh/site-functions", data_home),
Shell::Bash => format!("{}/bash-completion/completions", data_home),
Shell::Fish => {
format!("{}/fish/completions", data_home)
}
Shell::PowerShell => format!("{}/powershell/completions", data_home),
_ => format!("{}/completions", data_home),
};
Ok(PathBuf::from(dir))
}
fn get_completion_file_path(shell: Shell, completion_dir: &std::path::Path) -> Result<PathBuf> {
let file_name = match shell {
Shell::Zsh => "_wsb",
Shell::Bash => "wsb",
Shell::Fish => "wsb.fish",
Shell::PowerShell => "wsb.ps1",
_ => "wsb",
};
Ok(completion_dir.join(file_name))
}
fn print_activation_hint(shell: Shell, file_path: &std::path::Path) {
match shell {
Shell::Bash => {
wsb::errln!("# To enable wsb completions, add this to your ~/.bashrc:");
wsb::errln!("source '{}'", file_path.to_string_lossy());
}
Shell::Zsh => {
let completion_parent = file_path.parent().unwrap_or(std::path::Path::new(""));
wsb::errln!("# To enable wsb completions, add these lines to your ~/.zshrc:");
wsb::errln!("fpath=(\"{}\" $fpath)", completion_parent.to_string_lossy());
wsb::errln!("autoload -U compinit && compinit");
}
Shell::Fish => {
wsb::errln!(
"# wsb completions installed to: {}",
file_path.to_string_lossy()
);
}
Shell::PowerShell => {
wsb::errln!("# To enable wsb completions, add this to your PowerShell profile:");
wsb::errln!(". '{}'", file_path.to_string_lossy());
}
_ => {
wsb::errln!("# Completion script at: {}", file_path.to_string_lossy());
}
}
}
fn run_catalog_command(action: CatalogAction, ctx: &AppContext) -> Result<()> {
let catalogue = ctx.db.catalogue_repo();
let root = &ctx.workspace_root;
match action {
CatalogAction::List { repo } => {
let mut tests = catalogue.all()?;
if let Some(wanted) = &repo {
tests.retain(|test| &test.repo == wanted);
}
for test in &tests {
wsb::output::emit(wsb::output::Record::CatalogueTest {
number: test.number,
name: test.function_name.clone(),
repo: test.repo.clone(),
language: test.language.as_str().to_string(),
file: test.file_path.clone(),
line: test.line_number,
description: test.description.clone(),
});
}
if !wsb::output::is_jsonl() {
for test in &tests {
wsb::outln!(
"{:<10} {:<12} {:<44} {}",
test.number
.map(|n| format!("TEST{n}"))
.unwrap_or_else(|| "—".to_string()),
test.language.as_str(),
test.function_name,
test.location(),
);
}
wsb::outln!("\n{} tests", tests.len());
}
}
CatalogAction::Scan { quiet } => {
let scans = wsb::catalogue::scan::scan_workspace(root, &catalogue)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if !quiet {
let rows: Vec<wsb::report::Row> = scans
.iter()
.map(|scan| {
wsb::report::Row::new(
wsb::catalogue::scan::display_repo(&scan.repo),
format!("{} TESTS", scan.found),
format!("{} numbered", scan.numbered),
)
})
.collect();
let total: usize = scans.iter().map(|s| s.found).sum();
let conclusion =
format!("{total} tests across {} repositories", scans.len());
wsb::report::show(
"Catalogue scan",
&rows,
Some((conclusion.as_str(), wsb::report::Verdict::Ok)),
);
}
}
CatalogAction::Report => {
let survey = wsb::catalogue::report::Survey::read(&catalogue)?;
wsb::out!("{}", survey.render());
if !survey.is_clean() {
std::process::exit(wsb::output::finish(1));
}
}
CatalogAction::Parity {
family,
output,
git_add,
} => {
let members = wsb::catalogue::families::members(root, &family)?;
match output {
None => {
let parity =
wsb::catalogue::report::Parity::read(&catalogue, &members)?;
wsb::out!("{}", parity.render());
}
Some(path) => {
let markdown = wsb::catalogue::report::parity_markdown(
&catalogue, &members,
)?;
let path = if path.is_absolute() { path } else { root.join(path) };
std::fs::write(&path, markdown)
.with_context(|| format!("failed to write {}", path.display()))?;
wsb::outln!("wrote {}", path.display());
if git_add {
let repository = wsb::git_backend::discover_repo(&path)?;
wsb::git_backend::stage_paths(
&repository,
&[path.to_string_lossy().to_string()],
)?;
}
}
}
}
CatalogAction::Free { from, count } => {
for number in catalogue.free_numbers(from, count)? {
wsb::outln!("TEST{number}");
}
}
CatalogAction::Export {
output,
repo,
git_add,
} => {
let markdown =
wsb::catalogue::report::markdown(&catalogue, repo.as_deref())?;
let path = output.unwrap_or_else(|| root.join("TEST_CATALOG.md"));
std::fs::write(&path, markdown)
.with_context(|| format!("failed to write {}", path.display()))?;
wsb::outln!("wrote {}", path.display());
if git_add {
let repository = wsb::git_backend::discover_repo(root)?;
wsb::git_backend::stage_paths(
&repository,
&[path.to_string_lossy().to_string()],
)?;
}
}
}
Ok(())
}
fn validate_server_workspace(workspace: Option<PathBuf>) -> Result<PathBuf> {
let start_dir = match workspace {
Some(p) => wsb::platform::canonicalize(&p)
.with_context(|| format!("workspace path '{}' does not exist", p.display()))?,
None => std::env::current_dir().context("failed to get current directory")?,
};
let workspace_root = workspace::find_super_root(&start_dir).ok_or_else(|| {
anyhow::anyhow!(
"cannot start the workbench from '{}': not inside a git repository.\n\
The workbench serves an initialized wsb workspace.\n\
initialize this directory first:\n wsb init\n\
or change into an existing workspace and try again.",
start_dir.display()
)
})?;
if !workspace_root.join(".wsb").exists() {
bail!(
"cannot start the workbench from '{}': '{}' is not an initialized wsb workspace.\n\
'.wsb' is missing. initialize this repository first:\n wsb init\n\
or change into an existing initialized workspace and try again.",
start_dir.display(),
workspace_root.display()
);
}
Ok(workspace_root)
}
fn run_serve_command(
workspace_root: &Path,
port: Option<u16>,
listen: Option<std::net::IpAddr>,
debug: bool,
parent_pid: Option<u32>,
no_daemon: bool,
) -> Result<()> {
if no_daemon {
bail!(
"there is no workbench without a daemon: the server IS the daemon, and \
this run says not to have one (--no-daemon, or {} in the \
environment).\n \
Run `wsb serve` without it, and put --no-daemon on the commands \
that should stay in this process.",
wsb::daemon::NO_DAEMON_ENV
);
}
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let spawn = wsb::daemon::Spawn {
listen: listen.unwrap_or(wsb::daemon::LOOPBACK),
http_port: daemon_port(port),
parent_pid,
debug,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (client, info, started) =
wsb::daemon::ensure_running(&state_dir, workspace_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
let worktree = client
.resolve_worktree(workspace_root)
.map_err(|e| anyhow::anyhow!(e))?;
let page = format!(
"http://{}/w/{}",
std::net::SocketAddr::new(info.http_address, info.http_port),
worktree.id
);
if started {
wsb::outln!("workbench started at {page} (wsb-daemon pid {})", info.pid);
if listen.is_some_and(|address| !address.is_loopback()) {
wsb::outln!(
" reachable from other machines, and the workbench has no \
authentication: anyone who can reach it can read and edit this \
workspace, run git on it and write to GitHub as this machine's \
login. Put it behind a tunnel or a TLS reverse proxy."
);
}
} else {
wsb::outln!("workbench already running at {page} (wsb-daemon pid {})", info.pid);
if listen.is_some_and(|address| address != info.http_address) {
wsb::outln!(
" --listen applies only to a daemon this command starts. \
`wsb daemon stop` and then `wsb serve --listen …` moves it."
);
}
if port.is_some_and(|asked| asked != info.http_port) {
let asked = port.expect("just checked");
wsb::outln!(
" --port applies only to a daemon this command starts. \
`wsb daemon restart --port {asked}` moves it."
);
}
}
Ok(())
}
fn runs_without_the_daemon(workspace: Option<&Path>, no_daemon: bool) -> bool {
if no_daemon {
return true;
}
let Some(workspace_root) = workspace else {
return true;
};
!has_a_commit(workspace_root)
}
fn has_a_commit(workspace_root: &Path) -> bool {
wsb::git_backend::open_repo(workspace_root)
.map(|repo| repo.head_id().is_ok())
.unwrap_or(false)
}
fn attach_to_the_daemon(acting: &Path, no_daemon: bool) -> Result<()> {
let workspace = wsb::workspace::find_workspace_root(acting);
if runs_without_the_daemon(workspace.as_deref(), no_daemon) {
return Ok(());
}
let workspace_root = workspace.expect("a workspace, since the decision above found one");
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let spawn = wsb::daemon::Spawn {
listen: wsb::daemon::LOOPBACK,
http_port: daemon_port(None),
parent_pid: None,
debug: false,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (client, _info, _started) = wsb::daemon::ensure_running(&state_dir, &workspace_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::output::report_to_daemon(&client);
Ok(())
}
fn daemon_port(asked: Option<u16>) -> wsb::daemon::Port {
match asked {
Some(port) => wsb::daemon::Port::Exact(port),
None => wsb::daemon::Port::FirstFreeFrom(wsb::daemon::DEFAULT_HTTP_PORT),
}
}
fn run_daemon_command(action: DaemonAction) -> Result<()> {
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let running = match wsb::daemon::MachineRendezvous::read(&state_dir)
.map_err(|e| anyhow::anyhow!(e))?
{
Some(rendezvous) => {
let client = wsb::daemon::Client::from_rendezvous(&rendezvous)
.map_err(|e| anyhow::anyhow!(e))?;
client.info().ok().map(|info| (client, info))
}
None => None,
};
match action {
DaemonAction::Status => match running {
Some((_, info)) => {
wsb::outln!("wsb-daemon {} (pid {})", info.version, info.pid);
wsb::outln!(
" workbench http://{}{}",
std::net::SocketAddr::new(info.http_address, info.http_port),
if info.http_address.is_loopback() {
""
} else {
" (reachable beyond this machine — no authentication)"
}
);
wsb::outln!(" machine {} ({})", info.machine_name, info.machine_id);
if info.peer_port > 0 {
wsb::outln!(
" peers {}{}",
std::net::SocketAddr::new(info.peer_address, info.peer_port),
if info.peer_address.is_loopback() {
" (this machine only — no peer can reach it)"
} else {
""
}
);
} else if let Some(why) = &info.peer_refused {
wsb::outln!(" peers not listening: {why}");
} else {
wsb::outln!(" peers off (--peer-port 0)");
}
if info.worktrees.is_empty() {
wsb::outln!(" worktrees none open yet");
} else {
wsb::outln!(" worktrees {} open", info.worktrees.len());
}
for worktree in &info.worktrees {
wsb::outln!(" {:<28} {}", worktree.id, worktree.root);
}
report_service(&state_dir, Some(info.pid));
Ok(())
}
None => {
wsb::outln!("no daemon is running on this machine");
wsb::outln!(" one starts by itself with the next command, or `wsb serve`");
report_service(&state_dir, None);
Ok(())
}
},
DaemonAction::Stop => match running {
Some((client, info)) => {
stop_daemon(&client, &info)?;
wsb::daemon::wait_until_stopped(&state_dir, &info)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("stopped wsb-daemon (pid {})", info.pid);
Ok(())
}
None => {
wsb::outln!("no daemon is running on this machine");
Ok(())
}
},
DaemonAction::Restart { port } => {
if wsb::daemon::service::status(&state_dir)
.is_ok_and(|service| service.installed && service.active)
{
wsb::daemon::service::restart().map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("asked the supervisor to restart the daemon");
if let Some(port) = port {
wsb::outln!(
" --port {port} was ignored: a service is started with the port \
it was installed with (`wsb daemon install --port {port}`)"
);
}
return Ok(());
}
if let Some((client, info)) = running {
stop_daemon(&client, &info)?;
wsb::daemon::wait_until_stopped(&state_dir, &info)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("stopped wsb-daemon (pid {})", info.pid);
}
let workspace_root = std::env::current_dir()?;
let spawn = wsb::daemon::Spawn {
listen: wsb::daemon::LOOPBACK,
http_port: daemon_port(port),
parent_pid: None,
debug: false,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (_client, info, _started) =
wsb::daemon::ensure_running(&state_dir, &workspace_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"started wsb-daemon {} at http://127.0.0.1:{} (pid {})",
info.version,
info.http_port,
info.pid
);
Ok(())
}
DaemonAction::Log { lines } => {
let path = wsb::daemon::log_path(&state_dir);
wsb::outln!("{}", path.display());
match std::fs::read_to_string(&path) {
Ok(text) => {
let all: Vec<&str> = text.lines().collect();
for line in all.iter().skip(all.len().saturating_sub(lines)) {
wsb::outln!("{line}");
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
wsb::outln!(" (no daemon has written to it yet)");
Ok(())
}
Err(e) => Err(anyhow::anyhow!("cannot read {}: {e}", path.display())),
}
}
DaemonAction::Install { port, peer_port } => {
let workspace_root = std::env::current_dir()?;
let spawn = match (port, &running) {
(None, Some((_, info))) => wsb::daemon::service::spawn_for_service(
Some(info.http_port),
peer_port.or(Some(info.peer_port)),
),
_ => wsb::daemon::service::spawn_for_service(port, peer_port),
};
let record = wsb::daemon::service::install(&state_dir, &workspace_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
let supervisor = wsb::daemon::service::supervisor()
.map_err(|e| anyhow::anyhow!(e))?;
let definition = wsb::daemon::service::definition_path(supervisor)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"installed the wsb daemon as a service under {}",
supervisor.describe()
);
wsb::outln!(" definition {}", definition.display());
wsb::outln!(" starts {}", record.executable.display());
wsb::outln!(" workspace {}", record.workspace_root.display());
wsb::outln!(" log {}", record.log.display());
wsb::outln!(" PATH {}", record.path);
if record.autostart {
wsb::outln!(" at login yes (it was already enabled)");
} else {
wsb::outln!(" at login no — `wsb daemon enable` starts it at login, and now");
}
Ok(())
}
DaemonAction::Enable => {
let service_pid = wsb::daemon::service::status(&state_dir)
.ok()
.filter(|service| service.active)
.and_then(|service| service.pid);
if let Some((client, info)) = &running {
if service_pid != Some(info.pid) {
wsb::outln!(
"handing over from the daemon started on demand (pid {})",
info.pid
);
stop_daemon(client, info)?;
wsb::daemon::wait_until_stopped(&state_dir, info)
.map_err(|e| anyhow::anyhow!(e))?;
}
}
let record = wsb::daemon::service::enable(&state_dir)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"the wsb daemon starts at login, and is running now (workspace {})",
record.workspace_root.display()
);
report_linger(&state_dir);
Ok(())
}
DaemonAction::Disable => {
wsb::daemon::service::disable(&state_dir).map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("the wsb daemon no longer starts at login");
match running {
Some((_, info)) => wsb::outln!(
" the one running now (pid {}) keeps running; `wsb daemon stop` ends it",
info.pid
),
None => wsb::outln!(" one still starts by itself with the next command"),
}
Ok(())
}
DaemonAction::Uninstall => {
match wsb::daemon::service::uninstall(&state_dir).map_err(|e| anyhow::anyhow!(e))? {
true => {
wsb::outln!("removed the wsb daemon service from this machine");
wsb::outln!(" one still starts by itself with the next command");
}
false => wsb::outln!("no wsb daemon service is installed on this machine"),
}
Ok(())
}
}
}
fn report_service(state_dir: &Path, daemon_pid: Option<u32>) {
let Ok(service) = wsb::daemon::service::status(state_dir) else {
return;
};
if !service.installed {
return;
}
wsb::outln!(
" service {} — {}",
service.supervisor.describe(),
if service.autostart {
"starts at login"
} else {
"installed, does not start at login"
}
);
match (service.active, service.pid) {
(true, Some(pid)) if Some(pid) == daemon_pid => {
wsb::outln!(" the daemon above is the one it is keeping")
}
(true, Some(pid)) => wsb::outln!(
" it is running pid {pid}, which is not the daemon above"
),
(true, None) => wsb::outln!(" it is running"),
(false, _) => wsb::outln!(" it is not running one right now"),
}
if service.drifted {
wsb::outln!(
" the definition is not what this wsb would write — \
run `wsb daemon install` again"
);
}
if service.autostart && service.linger == Some(false) {
wsb::outln!(
" \"at login\" means at login: without lingering this user's \
services stop at logout (`loginctl enable-linger $USER`)"
);
}
}
fn report_linger(state_dir: &Path) {
let Ok(service) = wsb::daemon::service::status(state_dir) else {
return;
};
if service.linger == Some(false) {
wsb::outln!(
" this user does not linger, so its services stop at logout: \
`loginctl enable-linger $USER` keeps the daemon across sessions"
);
}
}
fn stop_daemon(client: &wsb::daemon::Client, info: &wsb::daemon::Info) -> Result<()> {
client
.shutdown()
.map_err(|error| anyhow::anyhow!("cannot stop wsb-daemon (pid {}): {error}", info.pid))
}
fn run_relationship_command(action: RelationshipAction, ctx: &AppContext) -> Result<()> {
match action {
RelationshipAction::Link {
from_entity,
from_type,
to_entity,
to_type,
relationship_type,
description,
} => {
link_entities(
from_entity,
from_type,
to_entity,
to_type,
relationship_type,
description,
ctx,
)?;
}
RelationshipAction::List {
entity_id,
entity_type,
relationship_type,
include_resolved,
} => {
list_entity_relationships(
entity_id,
entity_type,
relationship_type,
include_resolved,
ctx,
)?;
}
RelationshipAction::Unlink {
dependency_id,
force,
} => {
unlink_entities(dependency_id, force, ctx)?;
}
RelationshipAction::Resolve {
dependency_id,
description,
} => {
resolve_entity_relationship(dependency_id, description, ctx)?;
}
RelationshipAction::Stats { detailed, format } => {
show_relationship_stats(detailed, format, ctx)?;
}
}
Ok(())
}
fn link_entities(
_from_entity: String,
from_type: String,
_to_entity: String,
to_type: String,
_relationship_type: String,
_description: Option<String>,
ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let _project = db.project_repo.list(None, None).await?.into_iter().next();
let _from_entity_type = parse_entity_type(&from_type)?;
let _to_entity_type = parse_entity_type(&to_type)?;
wsb::outln!("Dependency creation not implemented in new schema");
Ok(())
})
}
fn list_entity_relationships(
entity_id: String,
entity_type: String,
_relationship_type: Option<String>,
_include_resolved: bool,
_ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
wsb::outln!("Relationship listing not implemented in new schema");
wsb::outln!(
"{} Relationships for {} {}",
"🔗".cyan(),
entity_type,
entity_id
);
Ok(())
})
}
fn unlink_entities(dependency_id: String, force: bool, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let service = ctx.db_service.clone();
if !force {
wsb::out!("Remove relationship {}? [y/N]: ", dependency_id);
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
wsb::outln!("Cancelled");
return Ok(());
}
}
let conn = service.get_connection()?;
conn.execute(
"DELETE FROM dependencies WHERE id = ?",
rusqlite::params![&dependency_id],
)?;
wsb::outln!("{} Removed relationship {}", "✅".green(), dependency_id);
Ok(())
})
}
fn resolve_entity_relationship(
dependency_id: String,
description: Option<String>,
_ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
wsb::outln!("Dependency resolution not implemented in new schema");
wsb::outln!("{} Resolved relationship {}", "✅".green(), dependency_id);
if let Some(desc) = description {
wsb::outln!(" Resolution: {}", desc);
}
Ok(())
})
}
fn show_relationship_stats(detailed: bool, format: String, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No active project"))?;
wsb::outln!("Project dependencies listing not implemented in new schema");
let dependencies: Vec<String> = vec![];
if format == "json" {
let stats = serde_json::json!({
"total_relationships": dependencies.len(),
"active_relationships": dependencies.len(), "resolved_relationships": 0, });
wsb::outln!("{}", serde_json::to_string_pretty(&stats)?);
} else {
wsb::outln!(
"{} Relationship Statistics for {}",
"📊".cyan(),
project.name
);
wsb::outln!(" Total relationships: {}", dependencies.len());
wsb::outln!(" Active relationships: {}", dependencies.len()); wsb::outln!(" Resolved relationships: {}", 0);
if detailed {
wsb::outln!(" Breakdown by type: Not yet implemented");
}
}
Ok(())
})
}
fn parse_entity_type(type_str: &str) -> Result<wsb::entities::EntityType> {
match type_str.to_lowercase().as_str() {
"project" => Ok(wsb::entities::EntityType::Project),
"feature" => Ok(wsb::entities::EntityType::Feature),
"task" => Ok(wsb::entities::EntityType::Task),
"session" => Ok(wsb::entities::EntityType::Session),
"directive" => Ok(wsb::entities::EntityType::Directive),
_ => Err(anyhow::anyhow!("Unknown entity type: {}", type_str)),
}
}
fn run_note_command(action: NoteAction, ctx: &AppContext) -> Result<()> {
match action {
NoteAction::Add {
entity_type,
entity_id,
title,
content,
note_type,
tags,
} => {
add_entity_note(entity_type, entity_id, title, content, note_type, tags, ctx)?;
}
NoteAction::AddProject {
title,
content,
note_type,
tags,
} => {
add_project_note(title, content, note_type, tags, ctx)?;
}
NoteAction::List {
entity_type,
entity_id,
note_type,
project_wide,
pinned,
} => {
list_notes(entity_type, entity_id, note_type, project_wide, pinned, ctx)?;
}
NoteAction::Search {
query,
note_type,
format,
} => {
search_notes(query, note_type, format, ctx)?;
}
NoteAction::Update {
note_id,
title,
content,
tags,
} => {
update_note(note_id, title, content, tags, ctx)?;
}
NoteAction::Delete { note_id, force } => {
delete_note(note_id, force, ctx)?;
}
NoteAction::Pin { note_id } => {
toggle_note_pin(note_id, ctx)?;
}
NoteAction::Link {
source_note_id,
target_id,
target_type,
entity_type,
link_type,
} => {
link_note_to_target(
source_note_id,
target_id,
target_type,
entity_type,
link_type,
ctx,
)?;
}
NoteAction::Unlink { link_id, force } => {
unlink_note(link_id, force, ctx)?;
}
NoteAction::ListLinks {
id,
incoming,
outgoing,
format,
} => {
list_note_links(id, incoming, outgoing, format, ctx)?;
}
}
Ok(())
}
fn add_entity_note(
entity_type: String,
_entity_id: String,
_title: String,
_content: String,
note_type: String,
_tags: Option<String>,
_ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let _entity_type_enum = parse_entity_type(&entity_type)?;
let _note_type_enum = parse_note_type(¬e_type)?;
wsb::outln!("Note creation not implemented in new schema");
Ok(())
})
}
fn add_project_note(
_title: String,
_content: String,
_note_type: String,
_tags: Option<String>,
ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let _project = db.project_repo.list(None, None).await?.into_iter().next();
wsb::outln!("Project note creation not implemented in new schema");
Ok(())
})
}
fn list_notes(
_entity_type: Option<String>,
_entity_id: Option<String>,
_note_type: Option<String>,
_project_wide: bool,
_pinned: bool,
ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let _project = db.project_repo.list(None, None).await?.into_iter().next();
wsb::outln!("Note listing not implemented in new schema");
let notes: Vec<String> = vec![];
if notes.is_empty() {
wsb::outln!("{} No notes found", "ℹ️".blue());
return Ok(());
}
wsb::outln!("{} Found {} notes", "📝".cyan(), notes.len());
for note in notes {
wsb::outln!(" Note: {}", note);
}
Ok(())
})
}
fn search_notes(
query: String,
_note_type: Option<String>,
format: String,
ctx: &AppContext,
) -> Result<()> {
let _ = &format;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let _project = db.project_repo.list(None, None).await?.into_iter().next();
let notes: Vec<String> = Vec::new();
let filtered_notes = notes;
if format == "json" {
wsb::outln!("{}", serde_json::to_string_pretty(&filtered_notes)?);
} else {
if filtered_notes.is_empty() {
wsb::outln!("{} No notes found matching '{}'", "ℹ️".blue(), query);
return Ok(());
}
wsb::outln!(
"{} Found {} notes matching '{}'",
"🔍".cyan(),
filtered_notes.len(),
query
);
for note in filtered_notes {
wsb::outln!(" Note: {}", note);
}
}
Ok(())
})
}
fn update_note(
note_id: String,
_title: Option<String>,
_content: Option<String>,
_tags: Option<String>,
_ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
wsb::outln!("{} Note {} updated", "✅".green(), note_id);
Ok(())
})
}
fn delete_note(note_id: String, force: bool, _ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
if !force {
wsb::out!("Delete note {}? (y/N): ", note_id);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
wsb::outln!("Cancelled");
return Ok(());
}
}
wsb::outln!("{} Note {} deleted", "✅".green(), note_id);
Ok(())
})
}
fn toggle_note_pin(note_id: String, _ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let is_pinned = false;
let status = if is_pinned { "pinned" } else { "unpinned" };
wsb::outln!("{} Note {} {}", "✅".green(), note_id, status);
Ok(())
})
}
fn link_note_to_target(
_source_note_id: String,
_target_id: String,
_target_type: String,
_entity_type: Option<String>,
_link_type: String,
ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let _project = db.project_repo.list(None, None).await?.into_iter().next();
anyhow::bail!("Note links are not yet implemented in the new schema");
})
}
fn run_database_command(action: DatabaseAction, ctx: &AppContext) -> Result<()> {
match action {
DatabaseAction::Backup {
backup_dir,
compress,
max_backups,
} => {
create_database_backup(backup_dir, compress, max_backups, ctx)?;
}
DatabaseAction::List { backup_dir, format } => {
list_database_backups(backup_dir, format)?;
}
DatabaseAction::Restore {
backup_id,
target,
force,
} => {
restore_database_backup(backup_id, target, force, ctx)?;
}
DatabaseAction::Cleanup {
backup_dir,
max_backups,
dry_run,
} => {
cleanup_database_backups(backup_dir, max_backups, dry_run)?;
}
DatabaseAction::Health { performance } => {
check_database_health(performance, ctx)?;
}
}
Ok(())
}
fn create_database_backup(
backup_dir: Option<String>,
compress: bool,
max_backups: usize,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use wsb::persist::local::database::{create_backup, BackupConfig};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
let mut config = BackupConfig::default();
if let Some(dir) = backup_dir {
config.backup_directory = PathBuf::from(dir);
}
config.compression_enabled = compress;
config.max_backups = max_backups;
wsb::outln!("{} Creating database backup...", "⏳".yellow());
let metadata = create_backup(&service, &db_path, &config).await?;
wsb::outln!("{} Database backup created successfully", "✅".green());
wsb::outln!(" Backup ID: {}", metadata.backup_id);
wsb::outln!(" Location: {}", metadata.backup_path.display());
wsb::outln!(" Size: {} bytes", metadata.size_bytes);
wsb::outln!(
" Compressed: {}",
if metadata.compression { "Yes" } else { "No" }
);
wsb::outln!(" Checksum: {}", &metadata.checksum[..16]);
Ok(())
})
}
fn list_database_backups(backup_dir: Option<String>, format: String) -> Result<()> {
use colored::*;
use wsb::persist::local::database::{list_backups, BackupConfig};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let mut config = BackupConfig::default();
if let Some(dir) = backup_dir {
config.backup_directory = PathBuf::from(dir);
}
let backups = list_backups(&config).await?;
if backups.is_empty() {
wsb::outln!(
"{} No database backups found in {}",
"ℹ️".blue(),
config.backup_directory.display()
);
return Ok(());
}
match format.as_str() {
"json" => {
wsb::outln!("{}", serde_json::to_string_pretty(&backups)?);
}
_ => {
wsb::outln!("{} Database Backups ({} found)", "📦".blue(), backups.len());
wsb::outln!();
for backup in &backups {
let size_mb = backup.size_bytes as f64 / 1024.0 / 1024.0;
let compression_info = if backup.compression {
" (compressed)"
} else {
""
};
wsb::outln!(" {} {}", "ID:".bold(), backup.backup_id);
wsb::outln!(
" {} {}",
"Date:".bold(),
backup.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
);
wsb::outln!(
" {} {}",
"Size:".bold(),
format!("{:.2} MB{}", size_mb, compression_info)
);
wsb::outln!(" {} {}", "Path:".bold(), backup.backup_path.display());
wsb::outln!(" {} {}", "Checksum:".bold(), &backup.checksum[..16]);
wsb::outln!();
}
}
}
Ok(())
})
}
fn restore_database_backup(
backup_id: String,
target: Option<String>,
force: bool,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use std::io::{self, Write};
use wsb::persist::local::database::{list_backups, restore_backup, BackupConfig};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let config = BackupConfig::default();
let backups = list_backups(&config).await?;
let backup_metadata = if backup_id.contains('/') || backup_id.contains('\\') {
backups
.iter()
.find(|b| b.backup_path.to_string_lossy().contains(&backup_id))
} else {
backups.iter().find(|b| b.backup_id == backup_id)
};
let backup_metadata =
backup_metadata.ok_or_else(|| anyhow::anyhow!("Backup not found: {}", backup_id))?;
let target_path = if let Some(target) = target {
PathBuf::from(target)
} else {
ctx.workspace_root.join(".wsb/wsb.db")
};
if target_path.exists() && !force {
wsb::out!(
"{} Database exists at {}. Overwrite? (y/N): ",
"⚠️".yellow(),
target_path.display()
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
wsb::outln!("{} Restore cancelled", "❌".red());
return Ok(());
}
}
wsb::outln!("{} Restoring database from backup...", "⏳".yellow());
wsb::outln!(
" Backup: {} ({})",
backup_metadata.backup_id,
backup_metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
);
wsb::outln!(" Target: {}", target_path.display());
restore_backup(backup_metadata, &target_path).await?;
wsb::outln!("{} Database restored successfully", "✅".green());
Ok(())
})
}
fn cleanup_database_backups(
backup_dir: Option<String>,
max_backups: usize,
dry_run: bool,
) -> Result<()> {
use colored::*;
use wsb::persist::local::database::{cleanup_old_backups, list_backups, BackupConfig};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let mut config = BackupConfig::default();
if let Some(dir) = backup_dir {
config.backup_directory = PathBuf::from(dir);
}
config.max_backups = max_backups;
let backups_before = list_backups(&config).await?;
if backups_before.len() <= max_backups {
wsb::outln!(
"{} No cleanup needed. {} backups found, {} allowed",
"ℹ️".blue(),
backups_before.len(),
max_backups
);
return Ok(());
}
let to_remove = backups_before.len() - max_backups;
if dry_run {
wsb::outln!("{} Would remove {} old backups:", "🔍".blue(), to_remove);
for backup in backups_before.iter().skip(max_backups) {
wsb::outln!(
" - {} ({})",
backup.backup_id,
backup.timestamp.format("%Y-%m-%d %H:%M:%S")
);
}
} else {
wsb::outln!("{} Cleaning up {} old backups...", "🧹".yellow(), to_remove);
cleanup_old_backups(&config).await?;
wsb::outln!("{} Cleanup completed", "✅".green());
}
Ok(())
})
}
fn check_database_health(performance: bool, ctx: &AppContext) -> Result<()> {
use colored::*;
use wsb::persist::local::database::{health_check, optimize_database};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
wsb::outln!("{} Checking database health...", "⏳".yellow());
let health = health_check(&service).await?;
wsb::outln!("{} Database Health Report", "🏥".blue());
wsb::outln!(
" {} {}",
"Connection:".bold(),
if health.connected {
"✅ OK".green()
} else {
"❌ Failed".red()
}
);
wsb::outln!(
" {} {} ms",
"Response time:".bold(),
health.response_time_ms
);
wsb::outln!(" {} {}", "Schema version:".bold(), health.schema_version);
wsb::outln!(" {} {}", "Projects:".bold(), health.project_count);
wsb::outln!(" {} {}", "Features:".bold(), health.feature_count);
wsb::outln!(" {} {}", "Tasks:".bold(), health.task_count);
wsb::outln!(" {} {}", "Sessions:".bold(), health.session_count);
wsb::outln!(" {} {}", "Notes:".bold(), health.note_count);
wsb::outln!(
" {} {}",
"FK violations:".bold(),
if health.foreign_key_violations == 0 {
format!("{} ✅", health.foreign_key_violations).green()
} else {
format!("{} ⚠️", health.foreign_key_violations).yellow()
}
);
if performance {
wsb::outln!();
wsb::outln!("{} Running performance optimization...", "⚡".yellow());
optimize_database(&service).await?;
wsb::outln!("{} Performance optimization completed", "✅".green());
}
Ok(())
})
}
fn run_continuity_command(action: ContinuityAction, ctx: &AppContext) -> Result<()> {
match action {
ContinuityAction::Save {
session_id,
focus,
notes,
} => {
save_session_continuity_state(session_id, focus, notes, ctx)?;
}
ContinuityAction::Load { session_id, format } => {
load_session_continuity_state(session_id, format, ctx)?;
}
ContinuityAction::Transfer {
from_session,
to_session,
force,
} => {
transfer_session_continuity(from_session, to_session, force, ctx)?;
}
ContinuityAction::List { project, format } => {
list_session_continuity_states(project, format, ctx)?;
}
ContinuityAction::Snapshot { project, format } => {
create_project_context_snapshot(project, format, ctx)?;
}
}
Ok(())
}
fn save_session_continuity_state(
session_id: String,
focus: String,
notes: Option<String>,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use wsb::persist::local::database::{
create_context_snapshot, save_session_continuity, SessionContinuityState,
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
let db = &ctx.db;
let project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No active project found"))?;
wsb::outln!("{} Creating session continuity state...", "⏳".yellow());
let context_snapshot = create_context_snapshot(&service, &project.id).await?;
let active_features = db
.feature_repo
.list(None, None)
.await?
.into_iter()
.filter(|f| {
f.project_id == project.id
&& matches!(
f.state.as_str(),
"implemented_no_tests" | "implemented_failing_tests"
)
})
.map(|f| f.id)
.collect();
let in_progress_tasks = db
.task_repo
.list(None, None)
.await?
.into_iter()
.filter(|t| t.project_id == project.id && t.status == "in_progress")
.map(|t| t.id)
.collect();
let state = SessionContinuityState {
session_id: session_id.clone(),
project_id: project.id.clone(),
context_snapshot,
active_features,
in_progress_tasks,
session_focus: focus.clone(),
conversation_context: notes.unwrap_or_else(|| "Session context saved".to_string()),
working_directory: std::env::current_dir()?.to_string_lossy().to_string(),
environment_state: HashMap::new(),
timestamp: chrono::Utc::now(),
};
save_session_continuity(&service, &state).await?;
wsb::outln!("{} Session continuity state saved", "✅".green());
wsb::outln!(" Session ID: {}", session_id);
wsb::outln!(" Project: {}", project.name);
wsb::outln!(" Focus: {}", focus);
wsb::outln!(" Active features: {}", state.active_features.len());
wsb::outln!(" In-progress tasks: {}", state.in_progress_tasks.len());
wsb::outln!(
" Recent achievements: {}",
state.context_snapshot.recent_achievements.len()
);
Ok(())
})
}
fn load_session_continuity_state(
session_id: String,
format: String,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use wsb::persist::local::database::load_session_continuity;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
let state = load_session_continuity(&service, &session_id).await?;
match state {
Some(state) => match format.as_str() {
"json" => {
wsb::outln!("{}", serde_json::to_string_pretty(&state)?);
}
"detailed" => {
wsb::outln!("{} Session Continuity State (Detailed)", "🔄".blue());
wsb::outln!(" {} {}", "Session ID:".bold(), state.session_id);
wsb::outln!(" {} {}", "Project ID:".bold(), state.project_id);
wsb::outln!(" {} {}", "Focus:".bold(), state.session_focus);
wsb::outln!(
" {} {}",
"Timestamp:".bold(),
state.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
);
wsb::outln!(" {} {}", "Working Dir:".bold(), state.working_directory);
wsb::outln!();
wsb::outln!("{} Context Snapshot:", "📸".blue());
wsb::outln!(
" {} {}",
"Phase:".bold(),
state.context_snapshot.current_phase
);
wsb::outln!(
" {} {}",
"Active features:".bold(),
state.active_features.len()
);
wsb::outln!(
" {} {}",
"In-progress tasks:".bold(),
state.in_progress_tasks.len()
);
if !state.context_snapshot.recent_achievements.is_empty() {
wsb::outln!();
wsb::outln!(" {} Recent Achievements:", "🏆".green());
for achievement in &state.context_snapshot.recent_achievements {
wsb::outln!(" - {}", achievement);
}
}
if !state.context_snapshot.active_issues.is_empty() {
wsb::outln!();
wsb::outln!(" {} Active Issues:", "⚠️".yellow());
for issue in &state.context_snapshot.active_issues {
wsb::outln!(" - {}", issue);
}
}
if !state.context_snapshot.next_priorities.is_empty() {
wsb::outln!();
wsb::outln!(" {} Next Priorities:", "🎯".blue());
for priority in &state.context_snapshot.next_priorities {
wsb::outln!(" - {}", priority);
}
}
}
_ => {
wsb::outln!("{} Session Continuity State (Summary)", "🔄".blue());
wsb::outln!(" {} {}", "Session:".bold(), state.session_id);
wsb::outln!(" {} {}", "Focus:".bold(), state.session_focus);
wsb::outln!(
" {} {}",
"Date:".bold(),
state.timestamp.format("%Y-%m-%d %H:%M:%S")
);
wsb::outln!(
" {} {} features, {} tasks",
"Active:".bold(),
state.active_features.len(),
state.in_progress_tasks.len()
);
wsb::outln!(
" {} {} achievements, {} issues, {} priorities",
"Context:".bold(),
state.context_snapshot.recent_achievements.len(),
state.context_snapshot.active_issues.len(),
state.context_snapshot.next_priorities.len()
);
}
},
None => {
wsb::outln!(
"{} No continuity state found for session {}",
"ℹ️".blue(),
session_id
);
}
}
Ok(())
})
}
fn transfer_session_continuity(
from_session: String,
to_session: String,
force: bool,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use std::io::{self, Write};
use wsb::persist::local::database::transfer_session_knowledge;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
if !force {
wsb::out!(
"{} Transfer knowledge from session {} to {}? (y/N): ",
"⚠️".yellow(),
from_session,
to_session
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
wsb::outln!("{} Transfer cancelled", "❌".red());
return Ok(());
}
}
wsb::outln!("{} Transferring session knowledge...", "⏳".yellow());
transfer_session_knowledge(&service, &from_session, &to_session).await?;
wsb::outln!("{} Knowledge transferred successfully", "✅".green());
wsb::outln!(" From: {}", from_session);
wsb::outln!(" To: {}", to_session);
Ok(())
})
}
fn list_session_continuity_states(
project: Option<String>,
format: String,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
let conn = service.get_connection()?;
let sql = if project.is_some() {
"SELECT session_id, project_id, state_data, timestamp FROM session_continuity_states WHERE project_id = ? ORDER BY timestamp DESC"
} else {
"SELECT session_id, project_id, state_data, timestamp FROM session_continuity_states ORDER BY timestamp DESC"
};
let mut stmt = conn.prepare(sql)?;
let rows: Vec<(String, String, String, String)> = if let Some(ref project_id) = project {
stmt.query_map(rusqlite::params![project_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?.filter_map(|r| r.ok()).collect()
} else {
stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?.filter_map(|r| r.ok()).collect()
};
if rows.is_empty() {
wsb::outln!("{} No session continuity states found", "ℹ️".blue());
return Ok(());
}
match format.as_str() {
"json" => {
let states: Vec<serde_json::Value> = rows
.iter()
.map(|(_, _, state_data, _)| {
serde_json::from_str(state_data)
.unwrap_or_else(|_| serde_json::Value::Null)
})
.collect();
wsb::outln!("{}", serde_json::to_string_pretty(&states)?);
}
_ => {
wsb::outln!(
"{} Session Continuity States ({} found)",
"🔄".blue(),
rows.len()
);
wsb::outln!();
for (session_id, project_id, state_data, timestamp) in &rows {
if let Ok(state) = serde_json::from_str::<serde_json::Value>(state_data) {
let focus = state
.get("session_focus")
.and_then(|v| v.as_str())
.unwrap_or("No focus specified");
let active_features = state
.get("active_features")
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
let in_progress_tasks = state
.get("in_progress_tasks")
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
wsb::outln!(" {} {}", "Session ID:".bold(), session_id);
wsb::outln!(" {} {}", "Project:".bold(), project_id);
wsb::outln!(" {} {}", "Focus:".bold(), focus);
wsb::outln!(" {} {}", "Date:".bold(), timestamp);
wsb::outln!(
" {} {} features, {} tasks",
"Active:".bold(),
active_features,
in_progress_tasks
);
wsb::outln!();
}
}
}
}
Ok(())
})
}
fn create_project_context_snapshot(
project: Option<String>,
format: String,
ctx: &AppContext,
) -> Result<()> {
use colored::*;
use wsb::persist::local::database::create_context_snapshot;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db_path = ctx.workspace_root.join(".wsb/wsb.db");
if !db_path.exists() {
wsb::outln!(
"{} No project database found at {}",
"❌".red(),
db_path.display()
);
return Ok(());
}
let service = ctx.db_service.clone();
let db = &ctx.db;
let project_id = if let Some(pid) = project {
pid
} else {
let current_project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No active project found"))?;
current_project.id
};
wsb::outln!("{} Creating context snapshot...", "⏳".yellow());
let snapshot = create_context_snapshot(&service, &project_id).await?;
match format.as_str() {
"json" => {
wsb::outln!("{}", serde_json::to_string_pretty(&snapshot)?);
}
_ => {
wsb::outln!("{} Project Context Snapshot", "📸".blue());
wsb::outln!(" {} {}", "Project:".bold(), project_id);
wsb::outln!(" {} {}", "Phase:".bold(), snapshot.current_phase);
wsb::outln!();
if !snapshot.recent_achievements.is_empty() {
wsb::outln!(
" {} Recent Achievements ({}):",
"🏆".green(),
snapshot.recent_achievements.len()
);
for achievement in &snapshot.recent_achievements {
wsb::outln!(" - {}", achievement);
}
wsb::outln!();
}
if !snapshot.active_issues.is_empty() {
wsb::outln!(
" {} Active Issues ({}):",
"⚠️".yellow(),
snapshot.active_issues.len()
);
for issue in &snapshot.active_issues {
wsb::outln!(" - {}", issue);
}
wsb::outln!();
}
if !snapshot.next_priorities.is_empty() {
wsb::outln!(
" {} Next Priorities ({}):",
"🎯".blue(),
snapshot.next_priorities.len()
);
for priority in &snapshot.next_priorities {
wsb::outln!(" - {}", priority);
}
wsb::outln!();
}
if snapshot.recent_achievements.is_empty()
&& snapshot.active_issues.is_empty()
&& snapshot.next_priorities.is_empty()
{
wsb::outln!(
" {} No significant context found - project may be in initial state",
"ℹ️".blue()
);
}
}
}
Ok(())
})
}
fn unlink_note(link_id: String, force: bool, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let _service = ctx.db_service.clone();
if !force {
wsb::out!("Remove link {}? (y/N): ", link_id);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
wsb::outln!("Cancelled");
return Ok(());
}
}
anyhow::bail!("Note links are not yet implemented in the new schema");
})
}
fn list_note_links(
id: String,
incoming: bool,
outgoing: bool,
format: String,
ctx: &AppContext,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let _service = ctx.db_service.clone();
let show_incoming = incoming || (!incoming && !outgoing);
let show_outgoing = outgoing || (!incoming && !outgoing);
let (outgoing_links, incoming_links) = if show_incoming || show_outgoing {
anyhow::bail!("Note links are not yet implemented in the new schema");
} else {
(Vec::<String>::new(), Vec::<String>::new())
};
if format == "json" {
let response = serde_json::json!({
"entity_id": id,
"outgoing_links": if show_outgoing { outgoing_links } else { Vec::new() },
"incoming_links": if show_incoming { incoming_links } else { Vec::new() }
});
wsb::outln!("{}", serde_json::to_string_pretty(&response)?);
} else {
wsb::outln!("{} Links for {}", "🔗".blue(), id);
if show_outgoing && !outgoing_links.is_empty() {
wsb::outln!("\n{} Outgoing Links:", "→".blue());
for link in &outgoing_links {
wsb::outln!(" Link: {}", link);
}
}
if show_incoming && !incoming_links.is_empty() {
wsb::outln!("\n{} Incoming Links:", "←".blue());
for link in &incoming_links {
wsb::outln!(" Link: {}", link);
}
}
if (show_outgoing && outgoing_links.is_empty())
&& (show_incoming && incoming_links.is_empty())
{
wsb::outln!(" No links found for {}", id);
}
}
Ok(())
})
}
fn parse_note_type(type_str: &str) -> Result<String> {
Ok(type_str.to_string())
}
fn handle_code_command(action: CodeAction) -> Result<()> {
use wsb::code_analysis::{
search::{AstSearchEngine, SearchOptions},
transform::{AstTransformEngine, CommonTransforms, TransformOptions, TransformRule},
SupportedLanguage,
};
match action {
CodeAction::Tree {
depth,
hidden,
sizes,
extensions,
no_ignore,
} => {
show_codebase_tree(depth, hidden, sizes, extensions, no_ignore)?;
}
CodeAction::Search {
pattern,
files,
language,
context,
max_matches,
format,
} => {
let lang = language.and_then(|l| match l.as_str() {
"rust" => Some(SupportedLanguage::Rust),
"javascript" | "js" => Some(SupportedLanguage::JavaScript),
"typescript" | "ts" => Some(SupportedLanguage::TypeScript),
"python" | "py" => Some(SupportedLanguage::Python),
"go" => Some(SupportedLanguage::Go),
"java" => Some(SupportedLanguage::Java),
"c" => Some(SupportedLanguage::C),
"cpp" | "c++" => Some(SupportedLanguage::Cpp),
_ => None,
});
let options = SearchOptions {
pattern,
language: lang,
include_context: context > 0,
context_lines: context,
max_matches: Some(max_matches),
..Default::default()
};
let engine = AstSearchEngine::new(options);
let results = engine.search_files(&files)?;
match format.as_str() {
"json" => {
wsb::outln!("{}", serde_json::to_string_pretty(&results)?);
}
_ => {
for (file_path, matches) in results {
wsb::outln!("\n{}:", file_path.display().to_string().bright_blue());
for search_match in matches {
wsb::outln!(
" {}:{} - {}",
search_match.line.to_string().yellow(),
search_match.column.to_string().yellow(),
search_match.matched_text.trim()
);
if !search_match.context_before.is_empty() {
for line in search_match.context_before.lines() {
wsb::outln!(" {}", line.dimmed());
}
}
if !search_match.context_after.is_empty() {
for line in search_match.context_after.lines() {
wsb::outln!(" {}", line.dimmed());
}
}
}
}
}
}
}
CodeAction::Transform {
pattern,
replacement,
files,
language,
dry_run,
no_backup,
max_changes,
} => {
let lang = language
.and_then(|l| match l.as_str() {
"rust" => Some(SupportedLanguage::Rust),
"javascript" | "js" => Some(SupportedLanguage::JavaScript),
"typescript" | "ts" => Some(SupportedLanguage::TypeScript),
"python" | "py" => Some(SupportedLanguage::Python),
"go" => Some(SupportedLanguage::Go),
"java" => Some(SupportedLanguage::Java),
"c" => Some(SupportedLanguage::C),
"cpp" | "c++" => Some(SupportedLanguage::Cpp),
_ => None,
})
.unwrap_or(SupportedLanguage::Rust);
let options = TransformOptions {
dry_run,
backup_files: !no_backup,
max_changes_per_file: Some(max_changes),
..Default::default()
};
let rule = TransformRule {
name: "user_transform".to_string(),
pattern,
replacement,
language: lang,
};
let engine = AstTransformEngine::new(options);
let results = engine.transform_files(&files, &rule)?;
for result in results {
if result.successful {
wsb::outln!(
"{}: {} changes applied",
result.file_path.display().to_string().green(),
result.changes_made.to_string().yellow()
);
if dry_run {
wsb::outln!(" (dry run - no files modified)");
}
} else {
wsb::outln!(
"{}: failed - {}",
result.file_path.display().to_string().red(),
result.error_message.unwrap_or_default()
);
}
}
}
CodeAction::Patterns { language, category } => {
let lang = match language.as_str() {
"rust" => SupportedLanguage::Rust,
"javascript" | "js" => SupportedLanguage::JavaScript,
"typescript" | "ts" => SupportedLanguage::TypeScript,
"python" | "py" => SupportedLanguage::Python,
"go" => SupportedLanguage::Go,
"java" => SupportedLanguage::Java,
"c" => SupportedLanguage::C,
"cpp" | "c++" => SupportedLanguage::Cpp,
_ => {
wsb::errln!("Unsupported language: {}", language);
return Ok(());
}
};
wsb::outln!("Common {} patterns for {}:", category, language);
if category == "transform" {
let transforms = CommonTransforms::for_language(lang);
for transform in transforms {
wsb::outln!(
" {}: {} -> {}",
transform.name.bright_blue(),
transform.pattern.yellow(),
transform.replacement.green()
);
}
} else {
wsb::outln!("Search patterns will be available in full implementation");
}
}
CodeAction::Analyze {
files,
language: _language,
analysis_type,
format,
} => {
wsb::outln!(
"Code Analysis ({}): analyzing {} files",
analysis_type,
files.len()
);
for file in files {
if let Ok(content) = std::fs::read_to_string(&file) {
let lines = content.lines().count();
let chars = content.len();
match format.as_str() {
"json" => {
wsb::outln!(
"{{\"file\": \"{}\", \"lines\": {}, \"chars\": {}}}",
file.display(),
lines,
chars
);
}
_ => {
wsb::outln!(
"{}: {} lines, {} characters",
file.display().to_string().bright_blue(),
lines.to_string().yellow(),
chars.to_string().yellow()
);
}
}
} else {
wsb::outln!("{}: could not read file", file.display().to_string().red());
}
}
}
}
Ok(())
}
fn dirs_from_root_to(root: &Path, leaf: &Path) -> Vec<PathBuf> {
let Ok(relative) = leaf.strip_prefix(root) else {
return vec![leaf.to_path_buf()];
};
let mut dirs = vec![root.to_path_buf()];
let mut current = root.to_path_buf();
for component in relative.components() {
current = current.join(component);
dirs.push(current.clone());
}
dirs
}
fn show_codebase_tree(
depth: usize,
show_hidden: bool,
show_sizes: bool,
extensions_filter: Option<String>,
no_ignore: bool,
) -> Result<()> {
use colored::Colorize;
use ignore::gitignore::GitignoreBuilder;
let current_dir = std::env::current_dir()?;
let project_root = find_project_root(¤t_dir);
wsb::outln!("{}", "📁 Codebase Structure".bright_blue().bold());
wsb::outln!(
"{} {}",
"Project Root:".bright_green(),
project_root.display()
);
wsb::outln!(
"{} {}",
"Current Location:".bright_yellow(),
current_dir.display()
);
if current_dir != project_root {
let relative_path = current_dir
.strip_prefix(&project_root)
.unwrap_or(¤t_dir);
wsb::outln!(
"{} {}",
"Relative Path:".bright_cyan(),
relative_path.display()
);
}
wsb::outln!();
let extensions: Option<Vec<String>> = extensions_filter.map(|ext_str| {
ext_str
.split(',')
.map(|s| s.trim().to_lowercase())
.collect()
});
let gitignore = if no_ignore {
None
} else {
let ignore_root = match wsb::git_backend::repo_root(&project_root) {
Ok(root) => root,
Err(_) => project_root.clone(),
};
let mut builder = GitignoreBuilder::new(&ignore_root);
for dir in dirs_from_root_to(&ignore_root, &project_root) {
let candidate = dir.join(".gitignore");
if !candidate.is_file() {
continue;
}
if let Some(error) = builder.add(&candidate) {
anyhow::bail!("failed to read {}: {}", candidate.display(), error);
}
}
Some(
builder
.build()
.context("failed to compile .gitignore rules for the codebase tree")?,
)
};
display_tree(
&project_root,
"",
depth,
0,
show_hidden,
show_sizes,
&extensions,
&gitignore,
)?;
Ok(())
}
fn find_project_root(current: &Path) -> std::path::PathBuf {
let mut path = current.to_path_buf();
let project_markers = [
"Cargo.toml",
"package.json",
"pyproject.toml",
"setup.py",
"composer.json",
"pom.xml",
"build.gradle",
"CMakeLists.txt",
".git",
".svn",
".hg",
"Makefile",
"go.mod",
];
loop {
for marker in &project_markers {
if path.join(marker).exists() {
return path;
}
}
if !path.pop() {
break;
}
}
current.to_path_buf()
}
fn display_tree(
dir: &Path,
prefix: &str,
max_depth: usize,
current_depth: usize,
show_hidden: bool,
show_sizes: bool,
extensions: &Option<Vec<String>>,
gitignore: &Option<ignore::gitignore::Gitignore>,
) -> Result<()> {
if current_depth >= max_depth {
return Ok(());
}
let entries = fs::read_dir(dir)?;
let mut entries: Vec<_> = entries.collect::<Result<Vec<_>, _>>()?;
if let Some(ref gi) = gitignore {
entries.retain(|entry| {
let path = entry.path();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let matched = gi.matched(&path, is_dir);
!matched.is_ignore()
});
}
entries.sort_by_key(|entry| {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
(!is_dir, entry.file_name())
});
let total_entries = entries.len();
for (index, entry) in entries.iter().enumerate() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
let path = entry.path();
let is_last = index == total_entries - 1;
if !show_hidden && file_name_str.starts_with('.') {
continue;
}
let is_dir = entry.file_type()?.is_dir();
let connector = if is_last { "└── " } else { "├── " };
let new_prefix = if is_last { " " } else { "│ " };
if !is_dir {
if let Some(ref exts) = extensions {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if !exts.contains(&ext_str) {
continue;
}
} else if !exts.is_empty() {
continue;
}
}
}
wsb::out!("{}{}", prefix, connector);
if is_dir {
wsb::out!("{}", file_name_str.bright_blue().bold());
} else {
let colored_name = match path.extension().and_then(|s| s.to_str()) {
Some("rs") => file_name_str.bright_red(),
Some("py") => file_name_str.bright_yellow(),
Some("js" | "ts" | "jsx" | "tsx") => file_name_str.bright_green(),
Some("json" | "yaml" | "yml" | "toml") => file_name_str.bright_cyan(),
Some("md" | "txt" | "doc") => file_name_str.white(),
Some("sh" | "bash" | "zsh") => file_name_str.bright_magenta(),
_ => file_name_str.normal(),
};
wsb::out!("{}", colored_name);
}
if show_sizes && !is_dir {
if let Ok(metadata) = entry.metadata() {
let size = format_file_size(metadata.len());
wsb::out!(" {}", size.dimmed());
}
}
wsb::outln!();
if is_dir && current_depth + 1 < max_depth {
let next_prefix = format!("{}{}", prefix, new_prefix);
display_tree(
&path,
&next_prefix,
max_depth,
current_depth + 1,
show_hidden,
show_sizes,
extensions,
gitignore,
)?;
}
}
Ok(())
}
fn format_file_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}
fn run_version_command(action: VersionAction, ctx: &AppContext) -> Result<()> {
match action {
VersionAction::Show { verbose, format } => handle_version_show(verbose, format, ctx),
VersionAction::Major { version } => handle_version_major(version, ctx),
VersionAction::Tag { prefix, message } => handle_version_tag(prefix, message, ctx),
VersionAction::Info { include_history } => handle_version_info(include_history, ctx),
}
}
fn handle_version_show(verbose: bool, format: String, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let db = &ctx.db;
let service = ctx.db_service.clone();
let project = db.project_repo.list(None, None).await?.into_iter().next();
let major_version = get_project_major_version(&service)?;
let version_info = wsb::state::VersionInfo::calculate_with_major_in_repo(major_version, &ctx.project_root)?;
match format.as_str() {
"json" => {
let json_output = if verbose {
let calc_info = wsb::state::VersionInfo::get_calculation_info_in_repo(major_version, &ctx.project_root)?;
serde_json::json!({
"version": version_info.full_version,
"major": major_version,
"minor": version_info.minor_version,
"patch": version_info.patch_version,
"project": project.map(|p| p.name.clone()).unwrap_or_else(|| "Unknown".to_string()),
"calculation": {
"total_commits": calc_info.total_commits,
"changes_since_release": calc_info.changes_since_release,
"last_release_tag": calc_info.last_release_tag,
"method": calc_info.calculation_method
}
})
} else {
serde_json::json!({
"version": version_info.full_version,
"major": major_version,
"minor": version_info.minor_version,
"patch": version_info.patch_version,
"project": project.map(|p| p.name.clone()).unwrap_or_else(|| "Unknown".to_string())
})
};
wsb::outln!("{}", serde_json::to_string_pretty(&json_output)?);
}
_ => {
wsb::outln!("{} {}", "Version:".blue().bold(), version_info.full_version.green().bold());
let project_name = project.map(|p| p.name.clone()).unwrap_or_else(|| "Unknown".to_string());
wsb::outln!("{} {}", "Project:".blue(), project_name);
if verbose {
let calc_info = wsb::state::VersionInfo::get_calculation_info_in_repo(major_version, &ctx.project_root)?;
wsb::outln!("\n{}", "Calculation Breakdown:".blue().bold());
wsb::outln!(" {} {}", "Major (DB):".blue(), major_version.to_string().yellow());
wsb::outln!(" {} {}", "Minor (commits):".blue(), version_info.minor_version.to_string().yellow());
wsb::outln!(" {} {}", "Patch (changes):".blue(), version_info.patch_version.to_string().yellow());
if let Some(tag) = calc_info.last_release_tag {
wsb::outln!(" {} {}", "Last release tag:".blue(), tag.green());
} else {
wsb::outln!(" {} {}", "Last release tag:".blue(), "none".yellow());
}
wsb::outln!(" {} {}", "Method:".blue(), calc_info.calculation_method.cyan());
}
}
}
anyhow::Ok(())
})
}
fn handle_version_major(version: u32, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let service = ctx.db_service.clone();
let conn = service.get_connection()?;
conn.execute(
"UPDATE projects SET major_version = ?, updated_at = datetime('now') WHERE id = (SELECT id FROM projects LIMIT 1)",
rusqlite::params![version as i64],
)?;
let version_info = wsb::state::VersionInfo::calculate_with_major_in_repo(version, &ctx.project_root)?;
info!("Major version set to {}, new version: {}", version, version_info.full_version);
wsb::outln!("{} Major version set to {}", "✅".green(), version.to_string().green().bold());
wsb::outln!("{} New version: {}", "🔢".blue(), version_info.full_version.green().bold());
info!("Version set successfully - minor/patch calculated from git state");
wsb::outln!("\n{} {}", "Note:".blue(), "Minor/patch are calculated from git state");
wsb::outln!("{} Use 'wsb version tag' to create a release tag", "💡".yellow());
anyhow::Ok(())
})
}
fn handle_version_tag(prefix: String, message: Option<String>, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let service = ctx.db_service.clone();
let major_version = get_project_major_version(&service)?;
let version_info = wsb::state::VersionInfo::calculate_with_major_in_repo(major_version, &ctx.project_root)?;
let tag_name = format!("{}{}", prefix, version_info.full_version);
let tag_message =
message.unwrap_or_else(|| format!("Release version {}", version_info.full_version));
let repo = wsb::git_backend::discover_repo(&ctx.project_root)?;
wsb::git_backend::create_tag(&repo, &tag_name, &tag_message)?;
info!("Created git tag: {}", tag_name);
wsb::outln!(
"{} Created git tag: {}",
"✅".green(),
tag_name.green().bold()
);
wsb::outln!("{} Message: {}", "📝".blue(), tag_message);
wsb::outln!(
"\n{} To push tag to remote: git push origin {}",
"💡".yellow(),
tag_name
);
anyhow::Ok(())
})
}
fn handle_version_info(include_history: bool, ctx: &AppContext) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let service = ctx.db_service.clone();
let major_version = get_project_major_version(&service)?;
let calc_info = wsb::state::VersionInfo::get_calculation_info_in_repo(major_version, &ctx.project_root)?;
let version_info = wsb::state::VersionInfo::calculate_with_major_in_repo(major_version, &ctx.project_root)?;
wsb::outln!("{}", "Version Calculation Information".blue().bold());
wsb::outln!("=====================================");
wsb::outln!();
wsb::outln!("{}", "Current Version Breakdown:".green().bold());
wsb::outln!(
" {} {}.{}.{}",
"Version:".blue(),
major_version.to_string().green(),
version_info.minor_version.to_string().yellow(),
version_info.patch_version.to_string().cyan()
);
wsb::outln!(
" {} {} (stored in database)",
"Major:".blue(),
major_version.to_string().green()
);
wsb::outln!(
" {} {} (total git commits)",
"Minor:".blue(),
version_info.minor_version.to_string().yellow()
);
wsb::outln!(
" {} {} (changes since last v{}.* tag)",
"Patch:".blue(),
version_info.patch_version.to_string().cyan(),
major_version
);
wsb::outln!();
wsb::outln!("{}", "Git Integration:".green().bold());
if let Some(ref git_root) = calc_info.git_root {
wsb::outln!(
" {} {}",
"Git Root:".blue(),
git_root.display().to_string().cyan()
);
}
if let Some(ref tag) = calc_info.last_release_tag {
wsb::outln!(" {} {}", "Last Release Tag:".blue(), tag.green());
} else {
wsb::outln!(" {} {}", "Last Release Tag:".blue(), "none found".yellow());
}
wsb::outln!();
wsb::outln!("{}", "Commands:".green().bold());
wsb::outln!(
" {} Set major version",
format!("wsb version major <number>").cyan()
);
wsb::outln!(" {} Create release tag", "wsb version tag".cyan());
wsb::outln!(" {} Show current version", "wsb version show".cyan());
wsb::outln!();
if include_history {
wsb::outln!("{}", "Git History Analysis:".green().bold());
let repo = wsb::git_backend::discover_repo(&ctx.project_root)?;
let tags = wsb::git_backend::list_tags(&repo)?;
if tags.is_empty() {
wsb::outln!(" {} {}", "Recent Tags:".blue(), "none found".yellow());
} else {
wsb::outln!(" {}:", "Recent Tags".blue());
for (i, tag) in tags.iter().take(5).enumerate() {
wsb::outln!(" {}. {}", i + 1, tag.green());
}
}
wsb::outln!();
}
anyhow::Ok(())
})
}
fn get_project_major_version(service: &wsb::persist::global::DatabaseService) -> Result<u32> {
let conn = service.get_connection()?;
let result: Option<i64> = conn
.query_row("SELECT major_version FROM projects LIMIT 1", [], |row| {
row.get(0)
})
.ok();
if let Some(version) = result {
Ok(version as u32)
} else {
Ok(0)
}
}
fn handle_test_command(
dry_run: bool,
install: bool,
args: Vec<String>,
project_root: &Path,
) -> Result<()> {
use wsb::state::detect_project_files;
let project_files = detect_project_files(&project_root)?;
if project_files.is_empty() {
wsb::outln!(
"{} No project files detected in current directory",
"⚠️".yellow()
);
wsb::outln!("{} Supported project types: Rust (Cargo.toml), Node.js (package.json), Python (pyproject.toml), PHP (composer.json), Dart (pubspec.yaml), Java (pom.xml, build.gradle), C++ (CMakeLists.txt)", "💡".blue());
return Ok(());
}
let (test_cmd, test_args, description) = determine_test_command(&project_files)?;
wsb::outln!("{} Detected project: {}", "🔍".blue(), description);
wsb::outln!(
"{} Test command: {} {}",
"🚀".green(),
test_cmd,
test_args.join(" ")
);
if !args.is_empty() {
wsb::outln!("{} Additional args: {}", "📋".blue(), args.join(" "));
}
if dry_run {
wsb::outln!("{} Dry run - would execute:", "👀".cyan());
let mut full_args = test_args.clone();
full_args.extend(args);
wsb::outln!(" {} {}", test_cmd, full_args.join(" "));
return Ok(());
}
if install {
ensure_test_runner_available(&test_cmd, &project_files)?;
}
let mut full_args = test_args.clone();
full_args.extend(args);
wsb::outln!("{} Running tests...", "🧪".green());
let mut command = wsb::git_backend::command_without_repo_scope(&test_cmd);
command.args(&full_args);
let status = command.status()?;
if status.success() {
wsb::outln!("{} Tests completed successfully!", "✅".green());
} else {
wsb::outln!(
"{} Tests failed with exit code: {}",
"❌".red(),
status.code().unwrap_or(-1)
);
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
fn determine_test_command(
project_files: &[wsb::state::ProjectFile],
) -> Result<(String, Vec<String>, String)> {
use wsb::state::ProjectFileType;
const PRIORITY: &[ProjectFileType] = &[
ProjectFileType::CargoToml,
ProjectFileType::PackageJson,
ProjectFileType::PyprojectToml,
ProjectFileType::SetupPy,
ProjectFileType::GoMod,
ProjectFileType::MixExs,
ProjectFileType::BuildSbt,
ProjectFileType::ShardYml,
ProjectFileType::JuliaProject,
ProjectFileType::ComposerJson,
ProjectFileType::PubspecYaml,
ProjectFileType::PomXml,
ProjectFileType::BuildGradle,
ProjectFileType::BuildGradleKts,
ProjectFileType::CMakeLists,
ProjectFileType::PackageSwift,
ProjectFileType::Gemspec,
ProjectFileType::Csproj,
];
for wanted in PRIORITY {
if project_files
.iter()
.any(|project_file| project_file.file_type == *wanted)
{
return Ok(test_command_for(wanted));
}
}
anyhow::bail!("No supported test runner found for detected project files")
}
fn test_command_for(file_type: &wsb::state::ProjectFileType) -> (String, Vec<String>, String) {
use wsb::state::ProjectFileType;
match file_type {
ProjectFileType::CargoToml => (
"cargo".to_string(),
vec![
"nextest".to_string(),
"run".to_string(),
"--no-fail-fast".to_string(),
],
"Rust (Cargo.toml)".to_string(),
),
ProjectFileType::PackageJson => (
"npm".to_string(),
vec!["test".to_string()],
"Node.js (package.json)".to_string(),
),
ProjectFileType::PyprojectToml => (
"pytest".to_string(),
vec![],
"Python (pyproject.toml)".to_string(),
),
ProjectFileType::SetupPy => (
"python".to_string(),
vec!["-m".to_string(), "pytest".to_string()],
"Python (setup.py)".to_string(),
),
ProjectFileType::ComposerJson => (
"vendor/bin/phpunit".to_string(),
vec![],
"PHP (composer.json)".to_string(),
),
ProjectFileType::PubspecYaml => (
"flutter".to_string(),
vec!["test".to_string()],
"Dart/Flutter (pubspec.yaml)".to_string(),
),
ProjectFileType::PomXml => (
"mvn".to_string(),
vec!["test".to_string()],
"Java Maven (pom.xml)".to_string(),
),
ProjectFileType::BuildGradle => (
"./gradlew".to_string(),
vec!["test".to_string()],
"Java Gradle (build.gradle)".to_string(),
),
ProjectFileType::BuildGradleKts => (
"./gradlew".to_string(),
vec!["test".to_string()],
"Kotlin Gradle (build.gradle.kts)".to_string(),
),
ProjectFileType::CMakeLists => (
"ctest".to_string(),
vec!["--output-on-failure".to_string()],
"C/C++ (CMakeLists.txt)".to_string(),
),
ProjectFileType::PackageSwift => (
"swift".to_string(),
vec!["test".to_string()],
"Swift (Package.swift)".to_string(),
),
ProjectFileType::Gemspec => (
"bundle".to_string(),
vec!["exec".to_string(), "rspec".to_string()],
"Ruby (gemspec)".to_string(),
),
ProjectFileType::Csproj => (
"dotnet".to_string(),
vec!["test".to_string()],
".NET (csproj)".to_string(),
),
ProjectFileType::GoMod => (
"go".to_string(),
vec!["test".to_string(), "./...".to_string()],
"Go (go.mod)".to_string(),
),
ProjectFileType::MixExs => (
"mix".to_string(),
vec!["test".to_string()],
"Elixir (mix.exs)".to_string(),
),
ProjectFileType::BuildSbt => (
"sbt".to_string(),
vec!["test".to_string()],
"Scala (build.sbt)".to_string(),
),
ProjectFileType::ShardYml => (
"crystal".to_string(),
vec!["spec".to_string()],
"Crystal (shard.yml)".to_string(),
),
ProjectFileType::JuliaProject => (
"julia".to_string(),
vec![
"--project=.".to_string(),
"-e".to_string(),
"using Pkg; Pkg.test()".to_string(),
],
"Julia (Project.toml)".to_string(),
),
}
}
fn ensure_test_runner_available(
test_cmd: &str,
project_files: &[wsb::state::ProjectFile],
) -> Result<()> {
use wsb::state::ProjectFileType;
if wsb::report::which(test_cmd).is_some() {
wsb::outln!(
"{} Test runner {} is already available",
"✅".green(),
test_cmd
);
return Ok(());
}
for project_file in project_files {
match project_file.file_type {
ProjectFileType::CargoToml if test_cmd.contains("nextest") => {
wsb::outln!("{} Installing cargo-nextest...", "📦".blue());
let status = wsb::git_backend::command_without_repo_scope("cargo")
.args(["install", "cargo-nextest", "--locked"])
.status()?;
if status.success() {
wsb::outln!("{} Successfully installed cargo-nextest", "✅".green());
} else {
wsb::outln!(
"{} Failed to install cargo-nextest, falling back to standard cargo test",
"⚠️".yellow()
);
}
return Ok(());
}
ProjectFileType::PyprojectToml | ProjectFileType::SetupPy
if test_cmd.contains("pytest") =>
{
wsb::outln!("{} Installing pytest...", "📦".blue());
let status = wsb::git_backend::command_without_repo_scope("pip")
.args(["install", "pytest"])
.status()?;
if status.success() {
wsb::outln!("{} Successfully installed pytest", "✅".green());
} else {
wsb::outln!("{} Failed to install pytest", "❌".red());
anyhow::bail!("Could not install pytest");
}
return Ok(());
}
_ => {}
}
}
wsb::outln!(
"{} Test runner {} not found, please install it manually",
"⚠️".yellow(),
test_cmd
);
Ok(())
}
fn run_sample_command(
project: bool,
data: bool,
force: bool,
output: String,
_ctx: &AppContext,
) -> Result<()> {
wsb::outln!("{}", "=== Sample Project & Data Creation ===".bold().blue());
if !project && !data {
wsb::outln!(
"{} Specify --project to create sample project structure",
"📁".blue()
);
wsb::outln!(
"{} Specify --data to populate database with test data",
"🗄️".blue()
);
wsb::outln!(
"{} Use both flags to create complete sample environment",
"🚀".green()
);
return Ok(());
}
let output_path = std::path::Path::new(&output);
if output_path.exists() && !force {
wsb::outln!(
"{} Output directory '{}' already exists (use --force to overwrite)",
"⚠️".yellow(),
output
);
return Ok(());
}
if project {
create_sample_project_in_dir(&output, force)?;
}
if data {
populate_sample_data_in_dir(&output, force)?;
}
if project && data {
info!(
"Sample project and data creation completed in: {}",
output_path.display()
);
wsb::outln!(
"{} Sample project and data creation completed!",
"✅".green().bold()
);
wsb::outln!(
"{} Sample project created in: {}",
"📁".blue(),
wsb::platform::canonicalize(output_path)
.unwrap_or_else(|_| output_path.to_path_buf())
.display()
);
wsb::outln!("{} To start the dashboard:", "💡".blue());
wsb::outln!(" cd {} && wsb serve --port 3000", output);
wsb::outln!(
"{} Then access dashboard at http://localhost:3000",
"🌐".cyan()
);
}
Ok(())
}
fn create_sample_project_in_dir(output_dir: &str, force: bool) -> Result<()> {
wsb::outln!(
"{} Creating sample project structure in {}...",
"📁".blue().bold(),
output_dir
);
let output_path = std::path::Path::new(output_dir);
if output_path.exists() && force {
std::fs::remove_dir_all(output_path)?;
wsb::outln!(" {} Removed existing directory", "🗑️".yellow());
}
std::fs::create_dir_all(output_path.join("internal"))?;
std::fs::create_dir_all(output_path.join(".wsb"))?;
std::fs::create_dir_all(output_path.join("src"))?;
std::fs::create_dir_all(output_path.join("tests"))?;
std::fs::create_dir_all(output_path.join("docs"))?;
let claude_content = r#"# Sample Project
## Project Overview
**Project Name**: Sample Dashboard Project
**Type**: Web dashboard with API backend
**Current Version**: 1.0.0
## Project Description
This is a sample project created to demonstrate the Workspace development suite capabilities including:
- Feature-centric development methodology
- Real-time project dashboard
- Comprehensive API endpoints
- Database-driven project management
## Current Status
**Development Phase**: Sample Data Demonstration
**Test Status**: ✅ Sample data populated
**Build Status**: ✅ Ready for development
## Key Features Working
- ✅ Project management dashboard
- ✅ Feature tracking and status monitoring
- ✅ Task management with state transitions
- ✅ Real-time API endpoints
- ✅ Database-backed storage
## Success Criteria
### Core Functionality
- ✅ Dashboard displays project metrics
- ✅ API endpoints return sample data
- ✅ Feature state management working
- ✅ Task tracking operational
### Quality Metrics
- ✅ All API endpoints responding
- ✅ Database queries optimized
- ✅ Sample data representative of real usage
## Next Steps
Use this sample project to:
1. Test dashboard functionality
2. Validate API endpoints
3. Experiment with feature management
4. Learn the development methodology
---
*Created by wsb sample command*"#;
std::fs::write(output_path.join("CLAUDE.md"), claude_content)?;
wsb::outln!(" {} Created CLAUDE.md", "✅".green());
let package_json = r#"{
"name": "sample-dashboard-project",
"version": "1.0.0",
"description": "Sample project for Workspace development suite",
"main": "index.js",
"scripts": {
"dev": "wsb serve",
"test": "wsb status --include-features --include-metrics"
},
"keywords": ["workspace", "dashboard", "sample"],
"author": "Workspace Development Suite",
"license": "MIT"
}"#;
std::fs::write(output_path.join("package.json"), package_json)?;
wsb::outln!(" {} Created package.json", "✅".green());
let readme_content = r#"# Sample Dashboard Project
A comprehensive sample project demonstrating the Workspace development methodology with real project data.
## Features
This sample includes:
- **10 sample features** across different categories (Frontend, Backend, Database, Security, etc.)
- **10 sample tasks** with various statuses and priorities
- **4 development sessions** showing project evolution
- **5 notes** including architecture decisions and issues
- **5 dependencies** between features and tasks
- **4 projects** in different states
## Getting Started
1. **Start the dashboard server:**
```bash
wsb serve --port 3000
```
2. **Access the web dashboard:**
Open http://localhost:3000 in your browser
3. **Explore the data:**
- View project metrics and status
- Browse features by category and state
- Check task progress and dependencies
- Review development sessions and notes
## Sample Data Overview
The sample data covers all possible states and scenarios:
### Features (10 total)
- **States**: implemented, in_progress, planned, tested, not_implemented, deprecated
- **Categories**: Frontend, Backend, Database, Security, Performance, Testing, Documentation, DevOps, Analytics, Mobile
- **Priorities**: critical, high, medium, low
### Tasks (10 total)
- **Statuses**: completed, in_progress, pending, blocked, cancelled
- **Categories**: feature, infrastructure, testing, security, performance, etc.
### Projects (4 total)
- E-Commerce Platform (active)
- AI Analytics Engine (active)
- Legacy CRM System (archived)
- Modern CRM Platform (in development)
## Learning the Methodology
This sample demonstrates:
- Feature-driven development approach
- Comprehensive task tracking
- Project state management
- Development session documentation
- Dependency relationship modeling
- Multi-project organization
---
*Generated by Workspace Sample Generator*"#;
std::fs::write(output_path.join("README.md"), readme_content)?;
wsb::outln!(" {} Created README.md", "✅".green());
wsb::outln!(" {} Initializing git repository...", "🔧".yellow());
init_sample_git_repo(output_path)?;
wsb::outln!(
"{} Sample project structure created in {}",
"✅".green().bold(),
output_dir
);
Ok(())
}
fn create_sample_source_files(project_path: &std::path::Path) -> Result<()> {
let app_js = r#"// Main application entry point
class DashboardApp {
constructor() {
this.apiBase = '/api';
this.currentUser = null;
this.init();
}
async init() {
await this.loadUserProfile();
this.setupEventListeners();
this.renderDashboard();
}
async loadUserProfile() {
try {
const response = await fetch(`${this.apiBase}/user/profile`);
this.currentUser = await response.json();
} catch (error) {
console.error('Failed to load user profile:', error);
}
}
setupEventListeners() {
document.getElementById('refresh-btn')?.addEventListener('click', () => {
this.refreshData();
});
}
renderDashboard() {
const container = document.getElementById('dashboard');
if (container) {
container.innerHTML = `
<h1>Welcome, ${this.currentUser?.name || 'User'}</h1>
<div class="metrics">
<div class="metric-card">
<h3>Active Projects</h3>
<span class="metric-value">12</span>
</div>
<div class="metric-card">
<h3>Tasks Completed</h3>
<span class="metric-value">84</span>
</div>
</div>
`;
}
}
async refreshData() {
console.log('Refreshing dashboard data...');
await this.loadUserProfile();
this.renderDashboard();
}
}
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new DashboardApp();
});
"#;
std::fs::write(project_path.join("src/app.js"), app_js)?;
let styles_css = r#"/* Dashboard Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
}
#dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #2c3e50;
margin-bottom: 30px;
font-weight: 300;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.metric-card h3 {
color: #666;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.metric-value {
font-size: 32px;
font-weight: 700;
color: #3498db;
}
#refresh-btn {
background: #3498db;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
#refresh-btn:hover {
background: #2980b9;
}
"#;
std::fs::write(project_path.join("src/styles.css"), styles_css)?;
let index_html = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sample Dashboard</title>
<link rel="stylesheet" href="src/styles.css">
</head>
<body>
<div id="dashboard">
<div class="loading">Loading dashboard...</div>
</div>
<button id="refresh-btn">Refresh Data</button>
<script src="src/app.js"></script>
</body>
</html>
"#;
std::fs::write(project_path.join("index.html"), index_html)?;
let test_js = r#"// Dashboard App Tests
describe('DashboardApp', () => {
let app;
beforeEach(() => {
document.body.innerHTML = '<div id="dashboard"></div>';
app = new DashboardApp();
});
test('should initialize with correct API base', () => {
expect(app.apiBase).toBe('/api');
});
test('should render welcome message', () => {
app.currentUser = { name: 'Test User' };
app.renderDashboard();
const dashboard = document.getElementById('dashboard');
expect(dashboard.innerHTML).toContain('Welcome, Test User');
});
test('should handle missing user gracefully', () => {
app.currentUser = null;
app.renderDashboard();
const dashboard = document.getElementById('dashboard');
expect(dashboard.innerHTML).toContain('Welcome, User');
});
});
"#;
std::fs::write(project_path.join("tests/app.test.js"), test_js)?;
let api_docs = r#"# API Documentation
## Overview
This document describes the REST API endpoints for the sample dashboard application.
## Authentication
All API endpoints require authentication via Bearer token in the Authorization header:
```
Authorization: Bearer <your-token>
```
## Endpoints
### User Profile
**GET /api/user/profile**
Returns the current user's profile information.
Response:
```json
{
"id": "user-123",
"name": "John Doe",
"email": "john@example.com",
"role": "developer",
"avatar_url": "https://example.com/avatar.jpg"
}
```
### Projects
**GET /api/projects**
Returns a list of all projects.
Query Parameters:
- `status` - Filter by project status (active, archived)
- `limit` - Number of results to return (default: 20)
Response:
```json
{
"projects": [
{
"id": "proj-123",
"name": "Sample Project",
"status": "active",
"created_at": "2024-01-15T10:00:00Z"
}
],
"total": 1
}
```
### Tasks
**POST /api/tasks**
Creates a new task.
Request Body:
```json
{
"title": "Implement feature X",
"description": "Add the new feature to the dashboard",
"priority": "high",
"assignee": "user-123"
}
```
## Error Responses
All errors follow this format:
```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request parameters",
"details": ["Missing required field: title"]
}
}
```
"#;
std::fs::write(project_path.join("docs/api.md"), api_docs)?;
Ok(())
}
fn init_sample_git_repo(project_path: &std::path::Path) -> Result<()> {
let repo = wsb::git_backend::init_repository(project_path)?;
wsb::git_backend::configure_local_user(
&repo,
"Sample Developer",
"developer@sample-project.com",
)?;
create_sample_source_files(project_path)?;
let commit_msg = "Initial project setup
- Added basic project structure with package.json
- Created src/, docs/, tests/ directories
- Added project documentation and README
- Initialized workspace with .wsb/ directory";
wsb::git_backend::commit_all(&repo, commit_msg)?
.context("Failed to create initial sample project commit")?;
create_development_commits(project_path)?;
wsb::outln!(
" {} Git repository initialized with sample commits",
"✅".green()
);
Ok(())
}
fn create_development_commits(project_path: &std::path::Path) -> Result<()> {
let auth_js = r#"// User authentication module
class AuthManager {
constructor(apiBase) {
this.apiBase = apiBase;
this.token = localStorage.getItem('auth_token');
}
async login(email, password) {
const response = await fetch(`${this.apiBase}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.token) {
this.token = data.token;
localStorage.setItem('auth_token', this.token);
}
return data;
}
logout() {
this.token = null;
localStorage.removeItem('auth_token');
}
isAuthenticated() {
return !!this.token;
}
getAuthHeaders() {
return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
}
}
"#;
std::fs::write(project_path.join("src/auth.js"), auth_js)?;
commit_sample_change(
project_path,
&["src/auth.js"],
"Add user authentication module
- Implement AuthManager class for login/logout
- Add token-based authentication support
- Store auth tokens in localStorage
- Provide helper methods for authenticated requests",
)?;
let updated_app = r#"// Main application entry point
class DashboardApp {
constructor() {
this.apiBase = '/api';
this.currentUser = null;
this.authManager = new AuthManager(this.apiBase);
this.init();
}
async init() {
if (!this.authManager.isAuthenticated()) {
this.showLoginForm();
return;
}
await this.loadUserProfile();
this.setupEventListeners();
this.renderDashboard();
}
async loadUserProfile() {
try {
const response = await fetch(`${this.apiBase}/user/profile`, {
headers: this.authManager.getAuthHeaders()
});
this.currentUser = await response.json();
} catch (error) {
console.error('Failed to load user profile:', error);
this.authManager.logout();
this.showLoginForm();
}
}
setupEventListeners() {
document.getElementById('refresh-btn')?.addEventListener('click', () => {
this.refreshData();
});
document.getElementById('logout-btn')?.addEventListener('click', () => {
this.authManager.logout();
this.showLoginForm();
});
}
renderDashboard() {
const container = document.getElementById('dashboard');
if (container) {
container.innerHTML = `
<div class="header">
<h1>Welcome, ${this.currentUser?.name || 'User'}</h1>
<button id="logout-btn">Logout</button>
</div>
<div class="metrics">
<div class="metric-card">
<h3>Active Projects</h3>
<span class="metric-value">12</span>
</div>
<div class="metric-card">
<h3>Tasks Completed</h3>
<span class="metric-value">84</span>
</div>
<div class="metric-card">
<h3>Team Members</h3>
<span class="metric-value">6</span>
</div>
</div>
`;
this.setupEventListeners();
}
}
showLoginForm() {
const container = document.getElementById('dashboard');
if (container) {
container.innerHTML = `
<div class="login-form">
<h2>Login</h2>
<form id="login-form">
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
`;
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
await this.authManager.login(email, password);
this.init();
} catch (error) {
alert('Login failed: ' + error.message);
}
});
}
}
async refreshData() {
console.log('Refreshing dashboard data...');
await this.loadUserProfile();
this.renderDashboard();
}
}
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new DashboardApp();
});
"#;
std::fs::write(project_path.join("src/app.js"), updated_app)?;
commit_sample_change(
project_path,
&["src/app.js"],
"Integrate authentication into dashboard
- Add login/logout functionality to main app
- Require authentication before showing dashboard
- Add logout button to dashboard header
- Handle authentication errors gracefully
- Show login form for unauthenticated users",
)?;
let updated_css = r#"/* Dashboard Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
}
#dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ddd;
}
.header h1 {
color: #2c3e50;
font-weight: 300;
margin: 0;
}
#logout-btn {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#logout-btn:hover {
background: #c0392b;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.metric-card h3 {
color: #666;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 32px;
font-weight: 700;
color: #3498db;
}
.login-form {
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.login-form h2 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
font-weight: 300;
}
.login-form input {
width: 100%;
padding: 12px 16px;
margin-bottom: 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.login-form input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.login-form button {
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.3s ease;
}
.login-form button:hover {
background: #2980b9;
}
#refresh-btn {
background: #27ae60;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s ease;
}
#refresh-btn:hover {
background: #219a52;
}
/* Responsive Design */
@media (max-width: 768px) {
#dashboard {
padding: 15px;
}
.header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.header h1 {
font-size: 24px;
}
.metrics {
grid-template-columns: 1fr;
gap: 15px;
}
.metric-card {
padding: 20px;
}
.metric-value {
font-size: 28px;
}
.login-form {
margin: 50px 20px;
padding: 30px 20px;
}
}
@media (max-width: 480px) {
.metric-card {
padding: 16px;
}
.metric-value {
font-size: 24px;
}
.login-form {
margin: 30px 15px;
padding: 25px 15px;
}
}
"#;
std::fs::write(project_path.join("src/styles.css"), updated_css)?;
commit_sample_change(
project_path,
&["src/styles.css"],
"Add responsive design and improved styling
- Implement responsive design for mobile devices
- Add hover effects and smooth transitions
- Improve login form styling and layout
- Add header with logout button styling
- Enhance metric cards with better visual hierarchy
- Add media queries for tablet and mobile breakpoints",
)?;
Ok(())
}
fn commit_sample_change(project_path: &Path, paths: &[&str], message: &str) -> Result<()> {
let repo = wsb::git_backend::open_repo(project_path)?;
let paths: Vec<String> = paths.iter().map(|path| (*path).to_string()).collect();
wsb::git_backend::stage_paths(&repo, &paths)?;
wsb::git_backend::commit_all(&repo, message)?.with_context(|| {
format!(
"Failed to commit sample change in {}",
project_path.display()
)
})?;
Ok(())
}
fn populate_sample_data_in_dir(output_dir: &str, force: bool) -> Result<()> {
wsb::outln!(
"{} Populating database with sample data in {}...",
"🗄️".blue().bold(),
output_dir
);
let output_path = std::path::Path::new(output_dir);
let db_path = output_path.join(".wsb/wsb.db");
if db_path.exists() && !force {
let output = wsb::git_backend::command_without_repo_scope("sqlite3")
.arg(&db_path)
.arg("SELECT COUNT(*) FROM features;")
.output();
if let Ok(output) = output {
if output.status.success() {
let count = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<i32>()
.unwrap_or(0);
if count > 0 {
wsb::outln!(
"{} Database already has {} features (use --force to overwrite)",
"⚠️".yellow(),
count
);
return Ok(());
}
}
}
}
tokio::runtime::Runtime::new()?
.block_on(async { populate_sample_data_in_dir_async(output_dir, force).await })
}
async fn populate_sample_data_in_dir_async(output_dir: &str, _force: bool) -> Result<()> {
let output_path = std::path::Path::new(output_dir);
let abs_path = if output_path.is_absolute() {
output_path.to_path_buf()
} else {
std::env::current_dir()?.join(output_path)
};
let ctx = AppContext::bootstrap(&abs_path)?;
let db = &ctx.db;
let current_project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No active project"))?;
let project_id = ¤t_project.id;
let test_data_sql = format!(
r#"-- Clear existing sample data (keep project)
DELETE FROM entity_audit_trails;
DELETE FROM feature_state_transitions;
DELETE FROM note_links;
DELETE FROM notes;
DELETE FROM dependencies;
DELETE FROM tests;
DELETE FROM templates;
DELETE FROM directives;
DELETE FROM milestones;
DELETE FROM sessions;
DELETE FROM tasks;
DELETE FROM features;
-- Insert sample features for current project
INSERT INTO features (id, project_id, code, name, description, category, state, test_status, priority, implementation_notes, test_evidence, created_at, updated_at, completed_at, estimated_effort, actual_effort) VALUES
('feat-001', '{project_id}', 'F-001', 'User Authentication Portal', 'Secure login system with multi-factor authentication and SSO integration', 'Frontend', 'tested_passing', 'All authentication tests passing', 'critical', 'Implemented using OAuth 2.0 and JWT tokens', 'All authentication tests passing', '2024-06-01T09:00:00Z', '2024-07-15T16:30:00Z', '2024-07-15T16:30:00Z', 40, 45),
('feat-002', '{project_id}', 'F-002', 'Dashboard Analytics Widget', 'Interactive dashboard with real-time metrics and customizable charts', 'Frontend', 'in_progress', 'Unit tests 70% complete', 'high', 'Using Chart.js and WebSocket for real-time updates', 'Unit tests 70% complete', '2024-06-15T10:00:00Z', '2024-08-01T14:20:00Z', NULL, 32, 28),
('feat-003', '{project_id}', 'F-003', 'Mobile Responsive Layout', 'Responsive design system supporting all device sizes', 'Frontend', 'tested_passing', 'Cross-browser testing completed', 'medium', 'Bootstrap 5 with custom breakpoints', 'Cross-browser testing completed', '2024-05-20T11:30:00Z', '2024-07-30T09:15:00Z', '2024-07-28T15:45:00Z', 24, 22),
('feat-004', '{project_id}', 'F-004', 'Progressive Web App', 'PWA capabilities with offline support and push notifications', 'Frontend', 'not_implemented', 'Not yet implemented', 'medium', NULL, NULL, '2024-07-01T08:00:00Z', '2024-07-01T08:00:00Z', NULL, 48, NULL),
('feat-005', '{project_id}', 'F-005', 'GraphQL API Gateway', 'Unified GraphQL endpoint aggregating multiple microservices', 'Backend', 'tested_passing', 'Load testing completed', 'critical', 'Apollo Server with federation and caching', 'Load testing completed', '2024-05-01T09:30:00Z', '2024-07-20T11:45:00Z', '2024-07-18T14:20:00Z', 60, 65),
('feat-006', '{project_id}', 'F-006', 'Payment Processing Service', 'Secure payment gateway with multiple provider support', 'Backend', 'tested_failing', 'Payment tests failing on edge cases', 'critical', 'Stripe and PayPal integration with webhook handling', 'Payment tests failing on edge cases', '2024-06-10T10:15:00Z', '2024-08-02T16:00:00Z', NULL, 80, 72),
('feat-007', '{project_id}', 'F-007', 'Inventory Management API', 'RESTful API for product catalog and stock management', 'Backend', 'tested_passing', 'API documentation complete', 'high', 'CRUD operations with optimistic locking', 'API documentation complete', '2024-05-15T14:00:00Z', '2024-07-10T10:30:00Z', '2024-07-08T16:45:00Z', 45, 42),
('feat-008', '{project_id}', 'F-008', 'Machine Learning Pipeline', 'Automated ML pipeline for recommendation engine', 'Backend', 'not_implemented', 'Not yet started', 'medium', NULL, NULL, '2024-07-15T09:00:00Z', '2024-07-15T09:00:00Z', NULL, 120, NULL),
('feat-009', '{project_id}', 'F-009', 'Search Functionality', 'Full-text search with filtering and pagination', 'Backend', 'in_progress', 'Integration tests passing', 'high', 'Elasticsearch with custom analyzers', 'Integration tests passing', '2024-07-20T11:00:00Z', '2024-08-01T14:30:00Z', NULL, 56, 48),
('feat-010', '{project_id}', 'F-010', 'Admin Dashboard', 'Administrative interface for system management', 'Frontend', 'tested_passing', 'UI tests complete', 'medium', 'React admin panel with role-based access', 'UI tests complete', '2024-06-01T10:00:00Z', '2024-07-25T16:00:00Z', '2024-07-25T16:00:00Z', 36, 34);
-- Insert comprehensive tasks
INSERT INTO tasks (id, project_id, code, title, description, category, status, priority, acceptance_criteria, validation_steps, evidence, assigned_to, created_at, updated_at, started_at, completed_at, estimated_effort, actual_effort) VALUES
('task-001', '{project_id}', 'TASK-001', 'Setup Production Infrastructure', 'Configure production AWS environment with security groups and VPC', 'infrastructure', 'completed', 'critical', 'Production environment accessible and secure', '1. VPC configured\n2. Security groups configured\n3. IAM roles configured', 'Infrastructure documentation completed', 'devops-team', '2024-03-01T09:00:00Z', '2024-03-15T16:30:00Z', '2024-03-01T09:30:00Z', '2024-03-15T16:30:00Z', 40, 42),
('task-002', '{project_id}', 'TASK-002', 'Database Schema Design', 'Design and implement normalized database schema', 'infrastructure', 'completed', 'critical', 'Schema supports all business requirements', '1. All entities normalized\n2. Foreign keys in place\n3. Indexes optimized', 'Schema documentation completed', 'backend-team', '2024-03-10T10:00:00Z', '2024-03-25T14:20:00Z', '2024-03-15T11:00:00Z', '2024-03-25T14:20:00Z', 32, 35),
('task-003', '{project_id}', 'TASK-003', 'User Authentication Implementation', 'Implement secure user registration and login system', 'feature', 'completed', 'critical', 'Users can register, login, and access protected resources', '1. Registration flow works\n2. Login with MFA functional\n3. JWT tokens validated', 'All authentication tests passing', 'fullstack-team', '2024-04-01T08:30:00Z', '2024-04-20T17:45:00Z', '2024-04-01T09:00:00Z', '2024-04-20T17:45:00Z', 48, 52),
('task-004', '{project_id}', 'TASK-004', 'Payment Gateway Integration', 'Integrate Stripe and PayPal payment processing', 'feature', 'in_progress', 'critical', 'Secure payment processing with proper error handling', '1. Stripe integration functional\n2. PayPal integration working\n3. Webhook handlers implemented', 'Stripe integration 90% complete', 'backend-team', '2024-06-01T09:00:00Z', '2024-08-02T15:30:00Z', '2024-06-01T09:30:00Z', NULL, 56, 48),
('task-005', '{project_id}', 'TASK-005', 'Mobile App Development', 'Develop React Native mobile application', 'feature', 'in_progress', 'high', 'Mobile app functional on iOS and Android', '1. App builds successfully\n2. Core features working\n3. App store guidelines met', 'iOS version 70% complete', 'mobile-team', '2024-05-15T10:00:00Z', '2024-08-01T14:15:00Z', '2024-05-20T08:00:00Z', NULL, 80, 65),
('task-006', '{project_id}', 'TASK-006', 'API Performance Optimization', 'Optimize API response times and database queries', 'performance', 'in_progress', 'high', 'API response times under 200ms for 95th percentile', '1. Load testing shows improvement\n2. Database optimization complete\n3. Caching strategy implemented', 'Database optimization 60% complete', 'backend-team', '2024-06-15T11:00:00Z', '2024-08-02T12:45:00Z', '2024-06-20T09:00:00Z', NULL, 44, 38),
('task-007', '{project_id}', 'TASK-007', 'Implement Search Functionality', 'Add full-text search with filtering and pagination', 'feature', 'pending', 'medium', 'Users can search and filter content effectively', '1. Search results relevant\n2. Filters work correctly\n3. Pagination handles large result sets', NULL, 'fullstack-team', '2024-07-01T10:00:00Z', '2024-07-15T16:20:00Z', NULL, NULL, 36, NULL),
('task-008', '{project_id}', 'TASK-008', 'Create Admin Dashboard', 'Build administrative interface for system management', 'feature', 'pending', 'medium', 'Administrators can manage users and settings', '1. User management functional\n2. System settings configurable\n3. Audit logs accessible', NULL, 'frontend-team', '2024-07-10T09:30:00Z', '2024-07-20T11:45:00Z', NULL, NULL, 40, NULL),
('task-009', '{project_id}', 'TASK-009', 'Implement Email Notifications', 'Set up transactional email system with templates', 'feature', 'in_progress', 'medium', 'System sends relevant notifications to users', '1. Email templates render correctly\n2. Delivery tracking functional\n3. Unsubscribe mechanism works', 'Email service configured', 'backend-team', '2024-06-20T08:00:00Z', '2024-08-01T13:30:00Z', '2024-06-25T10:00:00Z', NULL, 24, 20),
('task-010', '{project_id}', 'TASK-010', 'Third-party API Integration', 'Integrate external APIs for enhanced functionality', 'integration', 'blocked', 'high', 'External APIs properly integrated with error handling', '1. API calls successful\n2. Rate limiting respected\n3. Error scenarios handled', 'Blocked pending payment system completion', 'integration-team', '2024-07-01T11:00:00Z', '2024-07-25T15:00:00Z', NULL, NULL, 32, NULL);
-- Insert sessions
INSERT INTO sessions (id, project_id, title, description, state, started_at, ended_at, summary, achievements, created_at, updated_at) VALUES
('session-001', '{project_id}', 'Sprint 1 Development', 'Initial development sprint focusing on core authentication', 'completed', '2024-03-01T09:00:00Z', '2024-03-15T17:00:00Z', 'Successfully implemented user authentication system', 'Authentication system, database schema, production infrastructure', '2024-03-01T09:00:00Z', '2024-03-15T17:00:00Z'),
('session-002', '{project_id}', 'Sprint 2 Development', 'Payment system integration and testing', 'completed', '2024-03-16T09:00:00Z', '2024-03-30T17:00:00Z', 'Made significant progress on payment integration', 'GraphQL API, inventory management, testing framework', '2024-03-16T09:00:00Z', '2024-03-30T17:00:00Z'),
('session-003', '{project_id}', 'Sprint 3 Development', 'Performance optimization and monitoring setup', 'completed', '2024-04-01T09:00:00Z', '2024-04-15T17:00:00Z', 'Implemented comprehensive monitoring', 'CDN integration, monitoring dashboard, container orchestration', '2024-04-01T09:00:00Z', '2024-04-15T17:00:00Z'),
('session-004', '{project_id}', 'Sprint 4 Development', 'Mobile app development and API enhancements', 'active', '2024-07-15T09:00:00Z', NULL, NULL, NULL, '2024-07-15T09:00:00Z', '2024-08-02T16:00:00Z');
-- Insert dependencies
INSERT INTO dependencies (id, project_id, from_entity_id, from_entity_type, to_entity_id, to_entity_type, dependency_type, description, created_at) VALUES
('dep-001', '{project_id}', 'feat-002', 'feature', 'feat-001', 'feature', 'requires', 'Dashboard requires user authentication', '2024-06-15T10:00:00Z'),
('dep-002', '{project_id}', 'feat-004', 'feature', 'feat-001', 'feature', 'requires', 'PWA requires authentication system', '2024-07-01T08:00:00Z'),
('dep-003', '{project_id}', 'feat-007', 'feature', 'feat-001', 'feature', 'requires', 'Payment system requires authentication', '2024-06-10T10:15:00Z'),
('dep-004', '{project_id}', 'task-002', 'task', 'task-001', 'task', 'requires', 'Database schema requires infrastructure', '2024-03-10T10:00:00Z'),
('dep-005', '{project_id}', 'task-003', 'task', 'task-002', 'task', 'requires', 'Authentication requires database schema', '2024-04-01T08:30:00Z');
-- Insert notes
INSERT INTO notes (id, project_id, entity_id, entity_type, note_type, title, content, tags, author, is_project_wide, is_pinned, created_at, updated_at, metadata) VALUES
('note-001', '{project_id}', 'feat-001', 'feature', 'architecture', 'Authentication Architecture Decision', 'Decided to use OAuth 2.0 with PKCE for mobile clients and standard authorization code flow for web clients.', NULL, 'tech_lead', FALSE, FALSE, '2024-03-15T14:30:00Z', '2024-03-15T14:30:00Z', NULL),
('note-002', '{project_id}', 'feat-007', 'feature', 'decision', 'Payment Provider Selection', 'After evaluating Stripe, PayPal, and Square, decided on Stripe as primary with PayPal as secondary.', NULL, 'product_owner', FALSE, TRUE, '2024-06-10T15:20:00Z', '2024-06-10T15:20:00Z', NULL),
('note-003', '{project_id}', 'feat-002', 'feature', 'issue', 'Performance Bottleneck Identified', 'Database queries for user dashboard are taking 2-3 seconds due to N+1 problem.', '["performance", "database"]', 'dev_team', FALSE, TRUE, '2024-07-25T10:15:00Z', '2024-07-25T10:15:00Z', NULL),
('note-004', '{project_id}', 'task-004', 'task', 'issue', 'Payment Webhook Failures', 'Stripe webhooks are failing intermittently due to timeout issues.', '["payment", "webhook", "urgent"]', 'dev_team', FALSE, FALSE, '2024-07-28T14:30:00Z', '2024-07-28T14:30:00Z', NULL),
('note-005', '{project_id}', 'feat-004', 'feature', 'reference', 'Progressive Web App Enhancement', 'Consider implementing advanced PWA features like background sync and push notifications.', '["pwa", "enhancement"]', 'designer', FALSE, FALSE, '2024-07-01T12:00:00Z', '2024-07-01T12:00:00Z', NULL),
('note-006', '{project_id}', 'task-001', 'task', 'progress', 'Infrastructure Setup Complete', 'AWS infrastructure is fully configured with auto-scaling, monitoring, and backup systems operational.', '["infrastructure", "aws"]', 'devops_team', FALSE, FALSE, '2024-03-15T16:00:00Z', '2024-03-15T16:00:00Z', NULL),
('note-007', '{project_id}', 'feat-008', 'feature', 'evidence', 'Performance Benchmarks', 'API response times: 95th percentile under 150ms, throughput 5000 requests/second with caching layer.', '["performance", "metrics"]', 'qa_team', FALSE, FALSE, '2024-07-08T14:30:00Z', '2024-07-08T14:30:00Z', NULL),
('note-008', '{project_id}', NULL, NULL, 'architecture', 'Microservices Architecture Decision', 'Adopted microservices architecture with API gateway, service mesh, and distributed tracing for scalability.', '["architecture", "microservices"]', 'architect', TRUE, TRUE, '2024-02-15T10:00:00Z', '2024-02-15T10:00:00Z', NULL);
-- Insert directives (using correct column names: rule instead of description)
INSERT INTO directives (id, project_id, code, title, rule, category, priority, context, rationale, examples, created_at, updated_at) VALUES
('dir-001', '{project_id}', 'DEV-001', 'Code Review Mandatory', 'All code changes must undergo peer review before merging to main branch', 'development', 'high', 'All pull requests and merge requests', 'Ensure code quality and knowledge sharing', 'Pull requests blocked without approvals, CI/CD pipeline enforces checks', '2024-03-01T09:00:00Z', '2024-07-15T14:30:00Z'),
('dir-002', '{project_id}', 'SEC-001', 'Secret Management Policy', 'No secrets or API keys in source code, use environment variables or secure vaults', 'security', 'high', 'All code commits and deployments', 'Prevent security breaches and credential exposure', 'AWS Secrets Manager, HashiCorp Vault, environment-specific configurations', '2024-03-01T09:30:00Z', '2024-06-20T11:15:00Z'),
('dir-003', '{project_id}', 'TEST-001', 'Minimum Test Coverage', 'Maintain minimum 80% code coverage for all modules', 'testing', 'high', 'All production code modules', 'Ensure code reliability and catch regressions', 'Jest for frontend, pytest for backend, integration tests for APIs', '2024-03-15T10:00:00Z', '2024-07-30T16:45:00Z'),
('dir-004', '{project_id}', 'ARCH-001', 'API Versioning Strategy', 'All public APIs must include version numbers and maintain backward compatibility', 'architecture', 'medium', 'All public API endpoints', 'Prevent breaking changes for API consumers', 'v1/users, v2/orders, deprecation headers for sunset endpoints', '2024-04-01T08:00:00Z', '2024-07-01T12:30:00Z'),
('dir-005', '{project_id}', 'PERF-001', 'Performance Budgets', 'Frontend bundle size under 1MB, API response times under 200ms', 'performance', 'medium', 'All frontend builds and API endpoints', 'Maintain optimal user experience', 'Webpack bundle analyzer, New Relic monitoring, Lighthouse CI', '2024-05-01T11:00:00Z', '2024-07-20T09:45:00Z'),
('dir-006', '{project_id}', 'DOC-001', 'API Documentation Required', 'All API endpoints must have OpenAPI documentation with examples', 'documentation', 'medium', 'All API endpoints', 'Facilitate API integration and maintenance', 'Swagger UI, Redoc, automated doc generation from code annotations', '2024-04-15T14:00:00Z', '2024-06-30T10:20:00Z');
-- Insert milestones (using correct column names: no code column, no validation_evidence, achievement_summary, feature_count, task_count)
INSERT INTO milestones (id, project_id, title, description, target_date, achieved_date, success_criteria, status, completion_percentage, created_at, updated_at) VALUES
('milestone-001', '{project_id}', 'MVP Launch', 'Minimum viable product with core authentication and payment features', '2024-06-30T23:59:59Z', '2024-07-15T16:30:00Z', 'User registration, login, payment processing, basic dashboard functional', 'achieved', 100.0, '2024-03-01T09:00:00Z', '2024-07-15T17:00:00Z'),
('milestone-002', '{project_id}', 'Beta Release', 'Feature-complete beta with advanced analytics and mobile support', '2024-08-31T23:59:59Z', NULL, 'Mobile responsive design, advanced analytics, performance optimizations complete', 'in_progress', 65.0, '2024-06-01T10:00:00Z', '2024-08-01T14:20:00Z'),
('milestone-003', '{project_id}', 'Production Scaling', 'Production-ready system handling 10,000 concurrent users', '2024-10-31T23:59:59Z', NULL, 'Load testing passed, auto-scaling configured, monitoring comprehensive', 'planned', 0.0, '2024-07-01T11:00:00Z', '2024-07-15T16:00:00Z'),
('milestone-004', '{project_id}', 'Q4 Feature Expansion', 'Advanced features including AI recommendations and multi-tenant support', '2024-12-31T23:59:59Z', NULL, 'AI models deployed, multi-tenancy implemented, enterprise features complete', 'planned', 0.0, '2024-08-01T09:00:00Z', '2024-08-01T09:00:00Z');
-- Insert note links (using correct column names: target_id and target_type instead of target_entity_id and target_entity_type)
INSERT INTO note_links (id, project_id, source_note_id, target_id, target_type, link_type, created_at) VALUES
('link-001', '{project_id}', 'note-001', 'feat-007', 'feature', 'reference', '2024-06-10T15:30:00Z'),
('link-002', '{project_id}', 'note-002', 'task-004', 'task', 'related', '2024-06-10T15:45:00Z'),
('link-003', '{project_id}', 'note-003', 'feat-008', 'feature', 'blocks', '2024-07-25T10:30:00Z'),
('link-004', '{project_id}', 'note-004', 'note-002', 'note', 'response_to', '2024-07-28T14:45:00Z'),
('link-005', '{project_id}', 'note-005', 'milestone-002', 'milestone', 'depends_on', '2024-07-01T12:15:00Z'),
('link-006', '{project_id}', 'note-006', 'milestone-001', 'milestone', 'reference', '2024-03-15T16:15:00Z'),
('link-007', '{project_id}', 'note-007', 'dir-005', 'directive', 'reference', '2024-07-08T14:45:00Z'),
('link-008', '{project_id}', 'note-008', 'feat-006', 'feature', 'reference', '2024-05-01T09:45:00Z');
-- Insert audit trail records for recent activity
INSERT INTO entity_audit_trails (id, entity_id, entity_type, project_id, operation_type, field_changed, old_value, new_value, change_reason, triggered_by, session_id, timestamp, metadata) VALUES
('audit-001', 'feat-001', 'feature', '{project_id}', 'create', NULL, NULL, NULL, 'Initial feature creation during project setup', 'sample-generator', 'session-001', '2024-03-15T14:00:00Z', NULL),
('audit-002', 'feat-002', 'feature', '{project_id}', 'create', NULL, NULL, NULL, 'Dashboard feature added', 'sample-generator', 'session-001', '2024-03-15T14:05:00Z', NULL),
('audit-003', 'feat-001', 'feature', '{project_id}', 'update', 'state', 'planned', 'implemented', 'Feature implementation completed', 'development-team', 'session-001', '2024-03-15T16:30:00Z', '{{"completion_percentage": 100}}'),
('audit-004', 'task-001', 'task', '{project_id}', 'create', NULL, NULL, NULL, 'Infrastructure setup task created', 'sample-generator', 'session-001', '2024-03-10T09:00:00Z', NULL),
('audit-005', 'task-001', 'task', '{project_id}', 'update', 'status', 'pending', 'in_progress', 'Started infrastructure work', 'ops-team', 'session-001', '2024-03-10T10:00:00Z', NULL),
('audit-006', 'task-001', 'task', '{project_id}', 'update', 'status', 'in_progress', 'completed', 'Infrastructure deployment finished', 'ops-team', 'session-001', '2024-03-15T15:30:00Z', NULL),
('audit-007', 'feat-007', 'feature', '{project_id}', 'create', NULL, NULL, NULL, 'Payment system feature created', 'sample-generator', 'session-002', '2024-06-10T10:00:00Z', NULL),
('audit-008', 'feat-007', 'feature', '{project_id}', 'update', 'state', 'planned', 'implemented', 'Payment integration completed', 'backend-team', 'session-002', '2024-06-10T16:00:00Z', '{{"provider": "stripe"}}'),
('audit-009', 'task-004', 'task', '{project_id}', 'create', NULL, NULL, NULL, 'Payment API integration task', 'sample-generator', 'session-002', '2024-06-01T09:00:00Z', NULL),
('audit-010', 'task-004', 'task', '{project_id}', 'state_change', 'status', 'pending', 'blocked', 'Blocked by external API issues', 'integration-team', 'session-004', '2024-07-28T14:00:00Z', '{{"blocking_reason": "API rate limits"}}'),
('audit-011', 'feat-008', 'feature', '{project_id}', 'create', NULL, NULL, NULL, 'Performance monitoring feature added', 'sample-generator', 'session-003', '2024-04-01T09:30:00Z', NULL),
('audit-012', 'session-004', 'session', '{project_id}', 'create', NULL, NULL, NULL, 'New development session started', 'project-manager', NULL, '2024-07-15T09:00:00Z', '{{"sprint": "Sprint 4"}}'),
('audit-013', 'note-001', 'note', '{project_id}', 'create', NULL, NULL, NULL, 'Architecture decision documented', 'architect', 'session-001', '2024-03-15T14:30:00Z', '{{"category": "architecture"}}'),
('audit-014', 'feat-004', 'feature', '{project_id}', 'update', 'priority', 'medium', 'high', 'Increased priority for PWA features', 'product-manager', 'session-004', '2024-07-20T11:00:00Z', NULL),
('audit-015', 'milestone-001', 'milestone', '{project_id}', 'update', 'status', 'in_progress', 'achieved', 'Q4 Feature Expansion milestone completed', 'project-manager', 'session-004', '2024-07-30T17:00:00Z', '{{"completion_date": "2024-07-30"}}');
"#,
project_id = project_id
);
let db_path = abs_path.join(".wsb/wsb.db");
let db_path_str = db_path.to_string_lossy();
let mut child = wsb::git_backend::command_without_repo_scope("sqlite3")
.arg(&*db_path_str)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
stdin.write_all(test_data_sql.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
wsb::outln!(
"{} Warning: Some SQL statements failed: {}",
"⚠️".yellow(),
error
);
}
let project = db
.project_repo
.list(None, None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No active project"))?;
let features = db
.feature_repo
.list(None, None)
.await?
.into_iter()
.filter(|f| f.project_id == project.id)
.collect::<Vec<_>>();
let tasks = db
.task_repo
.list(None, None)
.await?
.into_iter()
.filter(|t| t.project_id == project.id)
.collect::<Vec<_>>();
wsb::outln!(" {} {} features created", "📋".cyan(), features.len());
wsb::outln!(" {} {} tasks created", "✅".cyan(), tasks.len());
wsb::outln!(" {} Comprehensive sample data loaded", "✅".green());
Ok(())
}
#[cfg(test)]
mod daemon_decision_tests {
use super::runs_without_the_daemon;
fn workspace(committed: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("a directory");
let git = |args: &[&str]| {
let out = wsb::git_backend::git_command()
.args(args)
.current_dir(dir.path())
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "--quiet"]);
std::fs::create_dir_all(dir.path().join(".wsb")).expect(".wsb");
std::fs::write(dir.path().join(".wsb").join("state.json"), "{\"version\": 1}\n").expect("state");
if committed {
std::fs::write(dir.path().join("README"), "x\n").expect("a file");
git(&["add", "README"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--quiet", "-m", "first"]);
}
dir
}
#[test]
fn test20013_which_runs_go_beside_the_daemon() {
let committed = workspace(true);
assert!(
!runs_without_the_daemon(Some(committed.path()), false),
"an ordinary command in a committed workspace must report to the daemon"
);
assert!(runs_without_the_daemon(Some(committed.path()), true), "--no-daemon means what it says");
assert!(runs_without_the_daemon(None, false), "no workspace, nothing to serve");
let fresh = workspace(false);
assert!(
runs_without_the_daemon(Some(fresh.path()), false),
"a workspace with no commit has no identity for a daemon to serve"
);
}
}
#[cfg(test)]
mod tests {
use super::owns_templates;
use wsb::state::RunCache;
use std::fs;
use std::path::PathBuf;
#[test]
fn test8826_ownership_follows_the_nearest_repository_boundary() {
let temp = tempfile::tempdir().expect("a scratch directory");
let root = temp.path();
let nested = root.join("family/member");
fs::create_dir_all(&nested).expect("the nested repository");
fs::write(nested.join("Cargo.toml.wstemplate"), "x").expect("the template");
let boundaries = vec![root.join("family"), nested.clone()];
let cache = RunCache::new();
assert!(
owns_templates(&nested, &boundaries, &cache)
.expect("the nested repository is scanned"),
"a repository owns the template sitting directly in it"
);
assert!(
!owns_templates(&root.join("family"), &boundaries, &cache)
.expect("the parent is scanned"),
"a parent must not claim a template that belongs to a repository nested in it"
);
fs::write(root.join("family/own.wstemplate"), "x").expect("the parent's own template");
assert!(
owns_templates(&root.join("family"), &boundaries, &RunCache::new())
.expect("the parent is scanned again"),
"a template outside every nested repository belongs to the parent"
);
let _ = PathBuf::new();
}
}
#[cfg(test)]
mod wstemplate_entry_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn declare(repo: &Path, name: &str, path: &str) {
let modules = repo.join(".gitmodules");
let mut text = fs::read_to_string(&modules).unwrap_or_default();
text.push_str(&format!(
"[submodule \"{name}\"]\n\tpath = {path}\n\turl = git@example.com:{name}.git\n"
));
fs::write(&modules, text).unwrap();
fs::create_dir_all(repo.join(path)).unwrap();
}
#[test]
fn test11364_a_nested_project_scans_the_whole_workspace() {
let place = TempDir::new().unwrap();
let workspace = place.path();
declare(workspace, "capdag", "capdag");
declare(&workspace.join("capdag"), "capdag-objc", "capdag-objc");
let nested = workspace.join("capdag/capdag-objc");
let entry = default_wstemplate_entry(&nested).expect("an entry is derived");
assert_eq!(entry.root, workspace, "the scan root must be the workspace");
assert_eq!(entry.alias, "capdag_objc");
}
#[test]
fn test11365_dashes_become_underscores() {
let place = TempDir::new().unwrap();
let workspace = place.path();
declare(workspace, "cartridge-sdks", "cartridge-sdks");
declare(
&workspace.join("cartridge-sdks"),
"capdag-cartridge-sdk-objc",
"capdag-cartridge-sdk-objc",
);
let nested = workspace.join("cartridge-sdks/capdag-cartridge-sdk-objc");
let entry = default_wstemplate_entry(&nested).expect("an entry is derived");
assert_eq!(entry.alias, "capdag_cartridge_sdk_objc");
assert!(!entry.alias.contains('-'), "{}", entry.alias);
}
#[test]
fn test11366_a_name_that_disagrees_with_its_directory_is_refused() {
let place = TempDir::new().unwrap();
let workspace = place.path();
declare(workspace, "something-else", "capdag-objc");
let nested = workspace.join("capdag-objc");
let error = default_wstemplate_entry(&nested)
.expect_err("a name that disagrees with its directory must be refused");
let said = format!("{error:#}");
assert!(said.contains("something-else"), "{said}");
assert!(said.contains("capdag-objc"), "{said}");
assert!(said.contains(".gitmodules"), "{said}");
}
#[test]
fn test11367_a_standalone_project_scans_itself() {
let place = TempDir::new().unwrap();
let alone = place.path().join("my-thing");
fs::create_dir_all(&alone).unwrap();
let entry = default_wstemplate_entry(&alone).expect("an entry is derived");
assert_eq!(entry.root, alone);
assert_eq!(entry.alias, "my_thing");
}
}