Skip to content

Commit d59a953

Browse files
CorrectRoadHclaude
andcommitted
feat(project): include archived in list + add archive/unarchive commands
`/me/projects` is called with `include_archived=true` so client-side `--status archived`/`all` filtering actually sees archived rows; prior behavior silently dropped them server-side. Adds `project archive` and `project unarchive` commands (PUT with `{active}`) and a live round-trip test covering create → list (active/archived/all) → archive → list → unarchive → list → delete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3b2888 commit d59a953

7 files changed

Lines changed: 242 additions & 1 deletion

File tree

src/api/client.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use super::models::NetworkProject;
3737
use super::models::NetworkRenameClient;
3838
use super::models::NetworkRenameProject;
3939
use super::models::NetworkRenameTag;
40+
use super::models::NetworkSetProjectActive;
4041
use super::models::NetworkTag;
4142
use super::models::NetworkTask;
4243
use super::models::NetworkTimeEntry;
@@ -110,6 +111,13 @@ pub trait ApiClient {
110111
new_name: String,
111112
) -> ResultWithDefaultError<Project>;
112113

114+
async fn set_project_archived(
115+
&self,
116+
workspace_id: i64,
117+
project_id: i64,
118+
archived: bool,
119+
) -> ResultWithDefaultError<Project>;
120+
113121
async fn get_tags(&self, workspace_id: i64) -> ResultWithDefaultError<Vec<Tag>>;
114122

115123
async fn create_tag(&self, workspace_id: i64, name: String) -> ResultWithDefaultError<Tag>;
@@ -252,7 +260,10 @@ impl V9ApiClient {
252260
}
253261

254262
async fn get_projects(&self) -> ResultWithDefaultError<Vec<NetworkProject>> {
255-
let url = format!("{}/me/projects", self.base_url);
263+
// `include_archived=true` is required for `/me/projects` to return archived
264+
// projects; without it the endpoint silently filters them out, so client-side
265+
// `--status archived`/`all` filtering would never see them.
266+
let url = format!("{}/me/projects?include_archived=true", self.base_url);
256267
self.get::<Vec<NetworkProject>>(url).await
257268
}
258269

@@ -1026,6 +1037,7 @@ fn cache_ttl_seconds_for_url(url: &str) -> i64 {
10261037
fn is_cacheable_get_url(url: &str) -> bool {
10271038
if url.ends_with("/me")
10281039
|| url.ends_with("/me/projects")
1040+
|| url.ends_with("/me/projects?include_archived=true")
10291041
|| url.ends_with("/me/tasks")
10301042
|| url.ends_with("/me/clients")
10311043
|| url.ends_with("/me/workspaces")
@@ -1562,6 +1574,34 @@ impl ApiClient for V9ApiClient {
15621574
})
15631575
}
15641576

1577+
async fn set_project_archived(
1578+
&self,
1579+
workspace_id: i64,
1580+
project_id: i64,
1581+
archived: bool,
1582+
) -> ResultWithDefaultError<Project> {
1583+
let url = format!(
1584+
"{}/workspaces/{}/projects/{}",
1585+
self.base_url, workspace_id, project_id
1586+
);
1587+
let body = NetworkSetProjectActive { active: !archived };
1588+
let network_project = self
1589+
.put::<NetworkProject, NetworkSetProjectActive>(url, &body)
1590+
.await?;
1591+
Ok(Project {
1592+
id: network_project.id,
1593+
name: network_project.name,
1594+
workspace_id: network_project.workspace_id,
1595+
client: None,
1596+
is_private: network_project.is_private,
1597+
active: network_project.active,
1598+
at: network_project.at,
1599+
created_at: network_project.created_at,
1600+
color: network_project.color,
1601+
billable: network_project.billable,
1602+
})
1603+
}
1604+
15651605
async fn get_tags(&self, workspace_id: i64) -> ResultWithDefaultError<Vec<Tag>> {
15661606
let network_tags = self.get_workspace_tags(workspace_id).await?;
15671607
Ok(network_tags

src/api/models.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ pub struct NetworkRenameProject {
167167
pub name: String,
168168
}
169169

170+
#[derive(Serialize, Deserialize, Clone, Debug)]
171+
pub struct NetworkSetProjectActive {
172+
pub active: bool,
173+
}
174+
170175
#[derive(Serialize, Deserialize, Clone, Debug)]
171176
pub struct NetworkCreateClient {
172177
pub name: String,

src/arguments.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,16 @@ Examples:
414414
#[arg(help = "Name of the project to delete")]
415415
name: String,
416416
},
417+
/// Archive a project (sets active=false) without deleting it.
418+
Archive {
419+
#[arg(help = "Name of the project to archive")]
420+
name: String,
421+
},
422+
/// Unarchive a project (sets active=true).
423+
Unarchive {
424+
#[arg(help = "Name of the project to unarchive")]
425+
name: String,
426+
},
417427
}
418428

419429
#[derive(Subcommand, Debug)]

src/commands/archive_project.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
use crate::api::client::ApiClient;
2+
use crate::error::ArgumentError;
3+
use crate::models::ResultWithDefaultError;
4+
5+
pub struct ArchiveProjectCommand;
6+
7+
impl ArchiveProjectCommand {
8+
pub async fn execute(
9+
api_client: impl ApiClient,
10+
name: String,
11+
archived: bool,
12+
) -> ResultWithDefaultError<()> {
13+
let project = api_client
14+
.get_projects_list()
15+
.await?
16+
.into_iter()
17+
.find(|p| p.name == name)
18+
.ok_or_else(|| {
19+
Box::new(ArgumentError::ResourceNotFound(format!(
20+
"No project found with name '{name}'"
21+
))) as Box<dyn std::error::Error + Send>
22+
})?;
23+
24+
let updated = api_client
25+
.set_project_archived(project.workspace_id, project.id, archived)
26+
.await?;
27+
let verb = if archived { "archived" } else { "unarchived" };
28+
println!("Project {verb} successfully\n{updated}");
29+
30+
Ok(())
31+
}
32+
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
use crate::api::client::MockApiClient;
38+
use crate::error::ApiError;
39+
use crate::models::Project;
40+
use chrono::Utc;
41+
use tokio_test::{assert_err, assert_ok};
42+
43+
fn mock_project(active: bool) -> Project {
44+
Project {
45+
id: 10,
46+
name: "Proj".to_string(),
47+
workspace_id: 1,
48+
client: None,
49+
is_private: false,
50+
active,
51+
at: Utc::now(),
52+
created_at: Utc::now(),
53+
color: "#06aaf5".to_string(),
54+
billable: None,
55+
}
56+
}
57+
58+
#[tokio::test]
59+
async fn archive_project_sends_active_false() {
60+
let mut api_client = MockApiClient::new();
61+
api_client
62+
.expect_get_projects_list()
63+
.returning(|| Ok(vec![mock_project(true)]));
64+
api_client
65+
.expect_set_project_archived()
66+
.withf(|wid, pid, archived| *wid == 1 && *pid == 10 && *archived)
67+
.returning(|_, _, _| Ok(mock_project(false)));
68+
69+
let result = ArchiveProjectCommand::execute(api_client, "Proj".to_string(), true).await;
70+
assert_ok!(result);
71+
}
72+
73+
#[tokio::test]
74+
async fn unarchive_project_sends_active_true() {
75+
let mut api_client = MockApiClient::new();
76+
api_client
77+
.expect_get_projects_list()
78+
.returning(|| Ok(vec![mock_project(false)]));
79+
api_client
80+
.expect_set_project_archived()
81+
.withf(|wid, pid, archived| *wid == 1 && *pid == 10 && !*archived)
82+
.returning(|_, _, _| Ok(mock_project(true)));
83+
84+
let result = ArchiveProjectCommand::execute(api_client, "Proj".to_string(), false).await;
85+
assert_ok!(result);
86+
}
87+
88+
#[tokio::test]
89+
async fn archive_project_handles_not_found() {
90+
let mut api_client = MockApiClient::new();
91+
api_client
92+
.expect_get_projects_list()
93+
.returning(|| Ok(vec![mock_project(true)]));
94+
95+
let result = ArchiveProjectCommand::execute(api_client, "Missing".to_string(), true).await;
96+
assert_err!(result);
97+
}
98+
99+
#[tokio::test]
100+
async fn archive_project_handles_api_failure() {
101+
let mut api_client = MockApiClient::new();
102+
api_client
103+
.expect_get_projects_list()
104+
.returning(|| Ok(vec![mock_project(true)]));
105+
api_client
106+
.expect_set_project_archived()
107+
.returning(|_, _, _| Err(Box::new(ApiError::Network)));
108+
109+
let result = ArchiveProjectCommand::execute(api_client, "Proj".to_string(), true).await;
110+
assert_err!(result);
111+
}
112+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod archive_project;
12
pub mod auth;
23
pub mod auth_status;
34
pub mod bulk_edit_time_entries;

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use arguments::{
2020
};
2121
use clap::error::ErrorKind;
2222
use clap::Parser;
23+
use commands::archive_project::ArchiveProjectCommand;
2324
use commands::auth::AuthenticationCommand;
2425
use commands::auth_status::AuthStatusCommand;
2526
use commands::bulk_edit_time_entries::BulkEditTimeEntriesCommand;
@@ -426,6 +427,12 @@ async fn execute_project_command(
426427
RenameProjectCommand::execute(api_client, old_name, new_name).await
427428
}
428429
ProjectAction::Delete { name } => DeleteProjectCommand::execute(api_client, name).await,
430+
ProjectAction::Archive { name } => {
431+
ArchiveProjectCommand::execute(api_client, name, true).await
432+
}
433+
ProjectAction::Unarchive { name } => {
434+
ArchiveProjectCommand::execute(api_client, name, false).await
435+
}
429436
}
430437
}
431438

tests/live_cli.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,72 @@ fn live_cli_workspace_resource_crud_succeeds() {
863863
assert!(find_item_by_name(&projects_after_delete, &renamed_project_name).is_none());
864864
}
865865

866+
/// Covers the archive/unarchive project commands and verifies that
867+
/// `project list --status archived` / `--status all` actually surface archived
868+
/// projects. Regression guard for the bug where `/me/projects` was called
869+
/// without `include_archived=true`, so client-side status filtering never saw
870+
/// archived rows.
871+
#[test]
872+
fn live_cli_project_archive_round_trip_succeeds() {
873+
let _guard = acquire_live_test_guard();
874+
require_live_test_env();
875+
876+
let mut cleanup = CleanupState::default();
877+
ensure_test_workspace_scope();
878+
let project_name = unique_description("archive-proj");
879+
880+
run_checked(&["project", "create", &project_name]);
881+
cleanup.project_name = Some(project_name.clone());
882+
wait_for_named_resource(
883+
"created project missing from project list",
884+
&["project", "list", "--json"],
885+
&project_name,
886+
);
887+
888+
// Default list (active) must include it; archived list must not.
889+
let active_before = run_json_array_command(&["project", "list", "--json"]);
890+
assert!(find_item_by_name(&active_before, &project_name).is_some());
891+
let archived_before =
892+
run_json_array_command(&["project", "list", "--json", "--status", "archived"]);
893+
assert!(find_item_by_name(&archived_before, &project_name).is_none());
894+
895+
// Archive it.
896+
run_checked(&["project", "archive", &project_name]);
897+
898+
// After archiving: must disappear from active, appear under archived + all.
899+
// Poll archived because mutations propagate asynchronously in OpenToggl.
900+
wait_for_named_resource(
901+
"archived project missing from --status archived list",
902+
&["project", "list", "--json", "--status", "archived"],
903+
&project_name,
904+
);
905+
let active_after_archive = run_json_array_command(&["project", "list", "--json"]);
906+
assert!(
907+
find_item_by_name(&active_after_archive, &project_name).is_none(),
908+
"archived project should not appear in default (active) list"
909+
);
910+
let all_after_archive =
911+
run_json_array_command(&["project", "list", "--json", "--status", "all"]);
912+
assert!(find_item_by_name(&all_after_archive, &project_name).is_some());
913+
914+
// Unarchive it.
915+
run_checked(&["project", "unarchive", &project_name]);
916+
wait_for_named_resource(
917+
"unarchived project missing from active list",
918+
&["project", "list", "--json"],
919+
&project_name,
920+
);
921+
let archived_after_unarchive =
922+
run_json_array_command(&["project", "list", "--json", "--status", "archived"]);
923+
assert!(
924+
find_item_by_name(&archived_after_unarchive, &project_name).is_none(),
925+
"unarchived project should not appear in archived list"
926+
);
927+
928+
run_checked(&["project", "delete", &project_name]);
929+
cleanup.project_name = None;
930+
}
931+
866932
#[test]
867933
fn live_cli_preferences_round_trip_succeeds() {
868934
let _guard = acquire_live_test_guard();

0 commit comments

Comments
 (0)