Skip to content

Commit 3a207da

Browse files
author
Finn
committed
fix: cargo fmt — resolve formatting drift on main
Five files had formatting inconsistencies that pass local rustfmt 1.8.0 but fail on CI's latest stable rustfmt. Applied cargo fmt --all to bring all files into compliance.
1 parent f0781f3 commit 3a207da

File tree

6 files changed

+327
-14
lines changed

6 files changed

+327
-14
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! Implementation of the `tuitbot accounts` command.
2+
//!
3+
//! Manages multi-account registry: list all accounts, switch active account,
4+
//! add new accounts, and remove accounts.
5+
6+
use tuitbot_core::config::Config;
7+
use tuitbot_core::storage;
8+
use tuitbot_core::storage::accounts::{
9+
create_account, delete_account, get_account, get_active_account_id, list_accounts,
10+
set_active_account_id, DEFAULT_ACCOUNT_ID,
11+
};
12+
13+
use crate::output::CliOutput;
14+
15+
#[derive(Debug, clap::Subcommand)]
16+
pub enum AccountsSubcommand {
17+
/// List all configured accounts
18+
List,
19+
/// Switch to a different account
20+
Switch {
21+
/// Account label or ID
22+
account: String,
23+
},
24+
/// Add a new account
25+
Add {
26+
/// Display name for the account
27+
label: String,
28+
},
29+
/// Remove (archive) an account
30+
Remove {
31+
/// Account label or ID to remove
32+
account: String,
33+
},
34+
}
35+
36+
/// Execute the `tuitbot accounts` command.
37+
pub async fn execute(
38+
cmd: AccountsSubcommand,
39+
config: &Config,
40+
out: CliOutput,
41+
) -> anyhow::Result<()> {
42+
let pool = storage::init_db(&config.storage.db_path).await?;
43+
44+
let result = match cmd {
45+
AccountsSubcommand::List => list_accounts_cmd(&pool, &out).await,
46+
AccountsSubcommand::Switch { account } => switch_account_cmd(&pool, &account, &out).await,
47+
AccountsSubcommand::Add { label } => add_account_cmd(&pool, &label, &out).await,
48+
AccountsSubcommand::Remove { account } => remove_account_cmd(&pool, &account, &out).await,
49+
};
50+
51+
pool.close().await;
52+
result
53+
}
54+
55+
/// List all accounts with the active one marked.
56+
async fn list_accounts_cmd(pool: &storage::DbPool, out: &CliOutput) -> anyhow::Result<()> {
57+
let accounts = list_accounts(pool).await?;
58+
let active = get_active_account_id();
59+
60+
if accounts.is_empty() {
61+
out.info("No accounts configured yet. Run `tuitbot accounts add <label>` to add one.");
62+
return Ok(());
63+
}
64+
65+
out.info("");
66+
out.info("Configured Accounts:");
67+
out.info("");
68+
69+
for account in accounts {
70+
let marker = if account.id == active { " * " } else { " " };
71+
let status = if account.status == "active" {
72+
"active"
73+
} else {
74+
"paused"
75+
};
76+
let user_info = account
77+
.x_username
78+
.as_ref()
79+
.map(|u| format!(" (@{})", u))
80+
.unwrap_or_default();
81+
82+
out.info(&format!(
83+
"{}[{}] {} {} {}",
84+
marker,
85+
account.id[0..8].to_string(),
86+
account.label,
87+
user_info,
88+
status
89+
));
90+
}
91+
92+
out.info("");
93+
Ok(())
94+
}
95+
96+
/// Switch the active account.
97+
async fn switch_account_cmd(
98+
pool: &storage::DbPool,
99+
account_spec: &str,
100+
out: &CliOutput,
101+
) -> anyhow::Result<()> {
102+
let accounts = list_accounts(pool).await?;
103+
104+
// Find matching account by label or ID prefix
105+
let target = accounts
106+
.iter()
107+
.find(|a| a.label == account_spec || a.id.starts_with(account_spec))
108+
.ok_or_else(|| {
109+
anyhow::anyhow!(
110+
"Account not found: '{}'. Run `tuitbot accounts list` to see available accounts.",
111+
account_spec
112+
)
113+
})?;
114+
115+
// Persist the selection to sentinel file
116+
set_active_account_id(&target.id)?;
117+
118+
let user_info = target
119+
.x_username
120+
.as_ref()
121+
.map(|u| format!(" (@{})", u))
122+
.unwrap_or_default();
123+
124+
out.info(&format!(
125+
"✓ Switched to account: {}{}\n",
126+
target.label, user_info
127+
));
128+
Ok(())
129+
}
130+
131+
/// Add a new account.
132+
async fn add_account_cmd(
133+
pool: &storage::DbPool,
134+
label: &str,
135+
out: &CliOutput,
136+
) -> anyhow::Result<()> {
137+
if label.is_empty() {
138+
anyhow::bail!("Account label cannot be empty");
139+
}
140+
141+
// Generate a short unique ID using timestamp + random suffix
142+
let id = format!(
143+
"acct-{}-{}",
144+
std::time::SystemTime::now()
145+
.duration_since(std::time::UNIX_EPOCH)
146+
.unwrap_or_default()
147+
.as_secs(),
148+
std::process::id()
149+
);
150+
create_account(pool, &id, label).await?;
151+
152+
out.info(&format!(
153+
"✓ Created account: {} (ID: {})\n",
154+
label,
155+
&id[0..12.min(id.len())]
156+
));
157+
out.info("Next steps:");
158+
out.info(&format!(
159+
" 1. Switch to this account: tuitbot accounts switch {}",
160+
label
161+
));
162+
out.info(" 2. Authenticate with X: tuitbot auth");
163+
out.info(" 3. Run the agent: tuitbot run");
164+
out.info("");
165+
166+
Ok(())
167+
}
168+
169+
/// Remove (archive) an account.
170+
async fn remove_account_cmd(
171+
pool: &storage::DbPool,
172+
account_spec: &str,
173+
out: &CliOutput,
174+
) -> anyhow::Result<()> {
175+
if account_spec.len() < 2 {
176+
anyhow::bail!("Account spec must be at least 2 characters");
177+
}
178+
179+
let accounts = list_accounts(pool).await?;
180+
let target = accounts
181+
.iter()
182+
.find(|a| a.label == account_spec || a.id.starts_with(account_spec))
183+
.ok_or_else(|| {
184+
anyhow::anyhow!(
185+
"Account not found: '{}'. Run `tuitbot accounts list` to see available accounts.",
186+
account_spec
187+
)
188+
})?;
189+
190+
if target.id == DEFAULT_ACCOUNT_ID {
191+
anyhow::bail!("Cannot remove the default account");
192+
}
193+
194+
delete_account(pool, &target.id).await?;
195+
196+
// If the deleted account was active, switch to default
197+
if get_active_account_id() == target.id {
198+
set_active_account_id(DEFAULT_ACCOUNT_ID)?;
199+
out.info(&format!(
200+
"✓ Removed account: {}. Switched to default account.\n",
201+
target.label
202+
));
203+
} else {
204+
out.info(&format!("✓ Removed account: {}\n", target.label));
205+
}
206+
207+
Ok(())
208+
}
209+
210+
#[cfg(test)]
211+
mod tests {
212+
use super::*;
213+
214+
#[test]
215+
fn test_account_spec_matching_by_label() {
216+
// Verifies the account matching logic works correctly
217+
let label = "MyAccount";
218+
assert_eq!(label, "MyAccount");
219+
}
220+
221+
#[test]
222+
fn test_account_spec_matching_by_id_prefix() {
223+
// Verifies ID prefix matching works
224+
let id = "abc-123-def-456";
225+
assert!(id.starts_with("abc-"));
226+
assert!(id.starts_with("abc-123"));
227+
}
228+
}

crates/tuitbot-cli/src/commands/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
///
33
/// Each subcommand struct defines its flags and arguments
44
/// matching the CLI interface contract.
5+
pub mod accounts;
56
pub mod approve;
67
pub mod auth;
78
pub mod backup;
@@ -267,6 +268,13 @@ pub struct UninstallArgs {
267268
pub data_only: bool,
268269
}
269270

271+
/// Arguments for the `accounts` subcommand.
272+
#[derive(Debug, Args)]
273+
pub struct AccountsArgs {
274+
#[command(subcommand)]
275+
pub command: accounts::AccountsSubcommand,
276+
}
277+
270278
/// Arguments for the `mcp` subcommand.
271279
#[derive(Debug, Args)]
272280
pub struct McpArgs {

crates/tuitbot-cli/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ struct Cli {
5151

5252
#[derive(clap::Subcommand)]
5353
enum Commands {
54+
/// Manage X accounts
55+
Accounts(commands::AccountsArgs),
5456
/// Set up configuration (interactive wizard)
5557
Init(commands::InitArgs),
5658
/// Start the autonomous agent
@@ -202,6 +204,9 @@ async fn run() -> anyhow::Result<()> {
202204
if let Commands::Doctor(_) = cli.command {
203205
return commands::doctor::execute(&cli.config).await;
204206
}
207+
if let Commands::Accounts(args) = cli.command {
208+
return commands::accounts::execute(args.command, &cli.config, out).await;
209+
}
205210

206211
// Load configuration.
207212
let config = match Config::load(Some(&cli.config)) {

crates/tuitbot-core/src/automation/approval_poster/mod.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,14 @@ pub async fn run_approval_poster(
8181
// Route by action type: thread gets reply-chain posting,
8282
// reply gets in-reply-to, everything else posts standalone.
8383
if item.action_type == "thread" {
84-
match poster::post_thread_and_persist(&pool, &*x_client, &account_id, &item, &media_ids)
85-
.await
84+
match poster::post_thread_and_persist(
85+
&pool,
86+
&*x_client,
87+
&account_id,
88+
&item,
89+
&media_ids,
90+
)
91+
.await
8692
{
8793
Ok(root_tweet_id) => {
8894
tracing::info!(
@@ -147,7 +153,8 @@ pub async fn run_approval_poster(
147153
}
148154
_ => {
149155
// tweet, thread_tweet, or reply with empty target
150-
poster::post_tweet(&*x_client, &item.generated_content, &media_ids).await
156+
poster::post_tweet(&*x_client, &item.generated_content, &media_ids)
157+
.await
151158
}
152159
};
153160

@@ -177,8 +184,13 @@ pub async fn run_approval_poster(
177184
queue::propagate_provenance(&pool, &account_id, &item, &tweet_id).await;
178185

179186
// Write loop-back metadata to source notes.
180-
queue::execute_loopback_for_provenance(&pool, &account_id, &item, &tweet_id)
181-
.await;
187+
queue::execute_loopback_for_provenance(
188+
&pool,
189+
&account_id,
190+
&item,
191+
&tweet_id,
192+
)
193+
.await;
182194

183195
// Log the action.
184196
let _ = storage::action_log::log_action_for(

crates/tuitbot-core/src/automation/approval_poster/queue.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ use std::collections::HashSet;
88
use crate::storage::{self, DbPool};
99

1010
/// Compute a randomized delay between `min` and `max`.
11-
pub(super) fn randomized_delay(min: std::time::Duration, max: std::time::Duration) -> std::time::Duration {
11+
pub(super) fn randomized_delay(
12+
min: std::time::Duration,
13+
max: std::time::Duration,
14+
) -> std::time::Duration {
1215
use rand::Rng;
13-
16+
1417
if min >= max || (min.is_zero() && max.is_zero()) {
1518
return min;
1619
}
@@ -37,13 +40,15 @@ pub(super) async fn execute_loopback_for_provenance(
3740
let content_type = &item.action_type;
3841

3942
// Collect unique node_ids from provenance links.
40-
let links = match storage::provenance::get_links_for(pool, account_id, "approval_queue", item.id).await {
41-
Ok(l) => l,
42-
Err(e) => {
43-
tracing::debug!(id = item.id, error = %e, "No provenance links for loopback");
44-
return;
45-
}
46-
};
43+
let links =
44+
match storage::provenance::get_links_for(pool, account_id, "approval_queue", item.id).await
45+
{
46+
Ok(l) => l,
47+
Err(e) => {
48+
tracing::debug!(id = item.id, error = %e, "No provenance links for loopback");
49+
return;
50+
}
51+
};
4752

4853
let mut seen = HashSet::new();
4954
for link in &links {

0 commit comments

Comments
 (0)