use super::{Open, Refusal};
use crate::core::entity::{
CustomViewId, MilestoneId, Page, Priority, Project, ProjectId, ProjectStatusId, TeamId, UserId,
};
use crate::core::store::{List, ListOf, Store};
#[derive(Debug, Clone, PartialEq)]
pub enum Request {
TeamProjects {
team_id: TeamId,
after: Option<String>,
},
ViewProjects {
view_id: CustomViewId,
after: Option<String>,
},
OpenInBrowser(String),
Find {
name: String,
},
Detail {
project_id: ProjectId,
},
Statuses,
Create {
draft: Draft,
},
Update {
project_id: ProjectId,
changes: Changes,
},
Delete {
project_id: ProjectId,
},
CreateMilestone {
project_id: ProjectId,
draft: MilestoneDraft,
},
UpdateMilestone {
milestone_id: MilestoneId,
changes: MilestoneChanges,
},
DeleteMilestone {
milestone_id: MilestoneId,
},
}
impl Request {
pub fn cursor(&self) -> Option<&str> {
match self {
Self::TeamProjects { after, .. } | Self::ViewProjects { after, .. } => after.as_deref(),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Draft {
pub name: String,
pub team_ids: Vec<TeamId>,
pub description: Option<String>,
pub content: Option<String>,
pub lead_id: Option<UserId>,
pub status_id: Option<ProjectStatusId>,
pub priority: Option<Priority>,
pub start_date: Option<String>,
pub target_date: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Changes {
pub name: Option<String>,
pub description: Option<String>,
pub content: Option<String>,
pub lead_id: Option<Option<UserId>>,
pub status_id: Option<ProjectStatusId>,
pub priority: Option<Priority>,
pub start_date: Option<Option<String>>,
pub target_date: Option<Option<String>>,
}
impl Changes {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MilestoneDraft {
pub name: String,
pub description: Option<String>,
pub target_date: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MilestoneChanges {
pub name: Option<String>,
pub description: Option<String>,
pub target_date: Option<Option<String>>,
}
fn named(name: Option<&String>) -> Result<(), Refusal> {
match name {
Some(name) if name.trim().is_empty() => Err(Refusal::NameRequired),
_ => Ok(()),
}
}
fn in_order(start: Option<&str>, target: Option<&str>) -> Result<(), Refusal> {
match (start, target) {
(Some(start), Some(target)) if target < start => Err(Refusal::EndsBeforeItStarts {
start: start.to_string(),
target: target.to_string(),
}),
_ => Ok(()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Projects {
Team,
View,
}
impl Projects {
fn list(self) -> List {
match self {
Self::Team => List::Projects,
Self::View => List::ViewProjects,
}
}
}
pub fn open_team_projects(store: &mut Store, team_id: TeamId) -> Open {
store
.open_list(List::Projects, ListOf::TeamProjects(team_id))
.into()
}
pub fn open_view_projects(store: &mut Store, view_id: CustomViewId) -> Open {
store
.open_list(List::ViewProjects, ListOf::ViewProjects(view_id))
.into()
}
pub fn next_page(store: &mut Store, projects: Projects) -> Option<super::Request> {
store.next_page(projects.list()).map(super::Request::page)
}
pub fn reload(store: &mut Store, projects: Projects) -> Option<super::Request> {
store.reload_list(projects.list()).map(super::Request::page)
}
pub fn find(name: &str) -> Result<Request, Refusal> {
let name = name.trim();
if name.is_empty() {
return Err(Refusal::NameRequired);
}
Ok(Request::Find {
name: name.to_string(),
})
}
pub fn open(project_id: ProjectId) -> Request {
Request::Detail { project_id }
}
pub fn load_statuses() -> Request {
Request::Statuses
}
pub fn create(mut draft: Draft) -> Result<Request, Refusal> {
if draft.name.trim().is_empty() {
return Err(Refusal::NameRequired);
}
if draft.team_ids.is_empty() {
return Err(Refusal::TeamRequired);
}
in_order(draft.start_date.as_deref(), draft.target_date.as_deref())?;
draft.description = draft.description.filter(|d| !d.trim().is_empty());
draft.content = draft.content.filter(|c| !c.trim().is_empty());
Ok(Request::Create { draft })
}
pub fn update(
store: &mut Store,
project_id: &ProjectId,
changes: Changes,
) -> Result<Request, Refusal> {
if changes.is_empty() {
return Err(Refusal::NothingToChange);
}
named(changes.name.as_ref())?;
in_order(
changes.start_date.clone().flatten().as_deref(),
changes.target_date.clone().flatten().as_deref(),
)?;
if let Some(name) = &changes.name {
for rows in [&mut store.projects, &mut store.view_projects] {
for project in rows.items.iter_mut().filter(|p| &p.id == project_id) {
project.name = name.clone();
}
}
}
Ok(Request::Update {
project_id: project_id.clone(),
changes,
})
}
pub fn delete(store: &mut Store, project_id: &ProjectId) -> Request {
for rows in [&mut store.projects, &mut store.view_projects] {
rows.items.retain(|p| &p.id != project_id);
}
Request::Delete {
project_id: project_id.clone(),
}
}
pub fn add_milestone(project_id: ProjectId, mut draft: MilestoneDraft) -> Result<Request, Refusal> {
if draft.name.trim().is_empty() {
return Err(Refusal::NameRequired);
}
draft.description = draft.description.filter(|d| !d.trim().is_empty());
Ok(Request::CreateMilestone { project_id, draft })
}
pub fn update_milestone(
milestone_id: MilestoneId,
changes: MilestoneChanges,
) -> Result<Request, Refusal> {
if changes == MilestoneChanges::default() {
return Err(Refusal::NothingToChange);
}
named(changes.name.as_ref())?;
Ok(Request::UpdateMilestone {
milestone_id,
changes,
})
}
pub fn delete_milestone(milestone_id: MilestoneId) -> Request {
Request::DeleteMilestone { milestone_id }
}
pub fn open_url(url: Option<String>) -> Result<Request, Refusal> {
url.map(Request::OpenInBrowser)
.ok_or(Refusal::NothingToOpen)
}
pub fn take_page(store: &mut Store, projects: Projects, of: &ListOf, page: Page<Project>) -> bool {
match projects {
Projects::Team => store.projects.accept_for(of, page),
Projects::View => store.view_projects.accept_for(of, page),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn project(id: &str) -> Project {
serde_json::from_str(&format!(r#"{{"id":"{id}","name":"{id}"}}"#)).unwrap()
}
fn draft(name: &str) -> Draft {
Draft {
name: name.into(),
team_ids: vec![TeamId::from("t")],
..Draft::default()
}
}
#[test]
fn a_project_is_found_by_its_name() {
assert_eq!(
find(" Forecast v2 "),
Ok(Request::Find {
name: "Forecast v2".into()
})
);
assert_eq!(find(" "), Err(Refusal::NameRequired));
}
#[test]
fn a_project_is_read_with_its_milestones() {
assert_eq!(
open(ProjectId::from("p")),
Request::Detail {
project_id: "p".into()
}
);
assert_eq!(load_statuses(), Request::Statuses);
}
#[test]
fn a_new_project_needs_a_name_and_a_team() {
assert_eq!(create(draft(" ")), Err(Refusal::NameRequired));
let teamless = Draft {
team_ids: Vec::new(),
..draft("Launch")
};
assert_eq!(create(teamless), Err(Refusal::TeamRequired));
}
#[test]
fn a_project_cannot_end_before_it_starts() {
let backwards = Draft {
start_date: Some("2026-10-01".into()),
target_date: Some("2026-09-01".into()),
..draft("Launch")
};
assert_eq!(
create(backwards),
Err(Refusal::EndsBeforeItStarts {
start: "2026-10-01".into(),
target: "2026-09-01".into()
})
);
}
#[test]
fn a_new_project_is_made_as_drafted() {
let described = Draft {
description: Some("".into()),
content: Some(" \n".into()),
target_date: Some("2026-12-01".into()),
..draft("Launch")
};
let Ok(Request::Create { draft: made }) = create(described) else {
panic!("expected a create");
};
assert_eq!(made.description, None);
assert_eq!(made.content, None);
assert_eq!(made.target_date.as_deref(), Some("2026-12-01"));
let written = Draft {
content: Some("## Why\n\nBecause.".into()),
..draft("Launch")
};
let Ok(Request::Create { draft: made }) = create(written) else {
panic!("expected a create");
};
assert_eq!(made.content.as_deref(), Some("## Why\n\nBecause."));
}
#[test]
fn an_empty_project_change_is_refused() {
let mut store = Store::default();
let p = ProjectId::from("p");
assert_eq!(
update(&mut store, &p, Changes::default()),
Err(Refusal::NothingToChange)
);
let unnamed = Changes {
name: Some("".into()),
..Changes::default()
};
assert_eq!(update(&mut store, &p, unnamed), Err(Refusal::NameRequired));
}
#[test]
fn a_projects_content_is_replaced_whole() {
let mut store = Store::default();
let p = ProjectId::from("p");
for text in ["## Why\n\nBecause.", ""] {
let changes = Changes {
content: Some(text.into()),
..Changes::default()
};
assert_eq!(
update(&mut store, &p, changes.clone()),
Ok(Request::Update {
project_id: p.clone(),
changes
})
);
}
}
#[test]
fn a_renamed_project_shows_at_once() {
let mut store = Store::default();
store.projects.items = vec![project("p")];
let changes = Changes {
name: Some("Renamed".into()),
target_date: Some(None),
..Changes::default()
};
let request = update(&mut store, &ProjectId::from("p"), changes.clone());
assert_eq!(
request,
Ok(Request::Update {
project_id: "p".into(),
changes
})
);
assert_eq!(store.projects.items[0].name, "Renamed");
}
#[test]
fn a_deleted_project_leaves_the_lists() {
let mut store = Store::default();
store.projects.items = vec![project("p"), project("q")];
assert_eq!(
delete(&mut store, &ProjectId::from("p")),
Request::Delete {
project_id: "p".into()
}
);
assert_eq!(store.projects.items.len(), 1);
}
#[test]
fn a_milestone_needs_a_name() {
let p = || ProjectId::from("p");
assert_eq!(
add_milestone(p(), MilestoneDraft::default()),
Err(Refusal::NameRequired)
);
let beta = MilestoneDraft {
name: "Beta".into(),
..MilestoneDraft::default()
};
assert_eq!(
add_milestone(p(), beta.clone()),
Ok(Request::CreateMilestone {
project_id: p(),
draft: beta
})
);
}
#[test]
fn milestones_are_changed_and_deleted() {
let m = || MilestoneId::from("m");
assert_eq!(
update_milestone(m(), MilestoneChanges::default()),
Err(Refusal::NothingToChange)
);
let unnamed = MilestoneChanges {
name: Some(" ".into()),
..MilestoneChanges::default()
};
assert_eq!(update_milestone(m(), unnamed), Err(Refusal::NameRequired));
let due = MilestoneChanges {
target_date: Some(Some("2026-11-01".into())),
..MilestoneChanges::default()
};
assert!(update_milestone(m(), due).is_ok());
assert_eq!(
delete_milestone(m()),
Request::DeleteMilestone { milestone_id: m() }
);
}
#[test]
fn a_project_opens_by_its_url() {
assert_eq!(
open_url(Some("https://linear.app/p".into())),
Ok(Request::OpenInBrowser("https://linear.app/p".into()))
);
assert_eq!(open_url(None), Err(Refusal::NothingToOpen));
}
#[test]
fn a_teams_projects_are_fetched_once() {
let mut store = Store::default();
assert_eq!(
open_team_projects(&mut store, TeamId::from("t")).request(),
Some(crate::core::usecase::Request::Project(
Request::TeamProjects {
team_id: "t".into(),
after: None
}
))
);
let of = ListOf::TeamProjects(TeamId::from("t"));
take_page(
&mut store,
Projects::Team,
&of,
Page::new(vec![project("p")], Default::default(), false),
);
assert_eq!(
open_team_projects(&mut store, TeamId::from("t")),
Open::Cached
);
}
#[test]
fn projects_for_a_team_left_behind_are_dropped() {
let mut store = Store::default();
open_team_projects(&mut store, TeamId::from("u"));
let of = ListOf::TeamProjects(TeamId::from("t"));
let page = Page::new(vec![project("p")], Default::default(), false);
assert!(!take_page(&mut store, Projects::Team, &of, page));
}
#[test]
fn a_project_view_lists_its_own_projects() {
let mut store = Store::default();
assert_eq!(
open_view_projects(&mut store, CustomViewId::from("v")).request(),
Some(crate::core::usecase::Request::Project(
Request::ViewProjects {
view_id: "v".into(),
after: None
}
))
);
assert!(store.projects.of.is_none());
assert_eq!(
reload(&mut store, Projects::View),
Some(crate::core::usecase::Request::Project(
Request::ViewProjects {
view_id: "v".into(),
after: None
}
))
);
assert_eq!(next_page(&mut store, Projects::View), None);
}
#[test]
fn a_teams_projects_reload_from_the_first_page() {
let mut store = Store::default();
open_team_projects(&mut store, TeamId::from("t"));
assert_eq!(
reload(&mut store, Projects::Team),
Some(crate::core::usecase::Request::Project(
Request::TeamProjects {
team_id: "t".into(),
after: None
}
))
);
assert_eq!(next_page(&mut store, Projects::Team), None);
}
}