1use super::mission::{AgentMission, key};
4use sim_kernel::{Expr, Symbol};
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum WorkspaceLeaseKind {
9 File,
11 Directory,
13 Crate,
15 IdeObject,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum WorkspaceLeaseMode {
22 SharedRead,
24 ExclusiveWrite,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct WorkspaceLease {
31 pub kind: WorkspaceLeaseKind,
33 pub repo: Option<String>,
35 pub target: String,
37 pub mode: WorkspaceLeaseMode,
39}
40
41impl WorkspaceLease {
42 pub fn file(repo: impl Into<String>, path: impl Into<String>) -> Self {
44 Self::repo_target(WorkspaceLeaseKind::File, repo, path)
45 }
46
47 pub fn directory(repo: impl Into<String>, path: impl Into<String>) -> Self {
49 Self::repo_target(WorkspaceLeaseKind::Directory, repo, path)
50 }
51
52 pub fn crate_name(repo: impl Into<String>, crate_name: impl Into<String>) -> Self {
54 Self::repo_target(WorkspaceLeaseKind::Crate, repo, crate_name)
55 }
56
57 pub fn ide_object(object_id: Symbol) -> Self {
59 Self {
60 kind: WorkspaceLeaseKind::IdeObject,
61 repo: None,
62 target: object_id.to_string(),
63 mode: WorkspaceLeaseMode::ExclusiveWrite,
64 }
65 }
66
67 pub fn for_read(mut self) -> Self {
69 self.mode = WorkspaceLeaseMode::SharedRead;
70 self
71 }
72
73 pub fn for_write(mut self) -> Self {
75 self.mode = WorkspaceLeaseMode::ExclusiveWrite;
76 self
77 }
78
79 pub(crate) fn as_expr(&self) -> Expr {
80 let mut entries = vec![
81 key(
82 "kind",
83 Expr::Symbol(Symbol::new(lease_kind_label(&self.kind))),
84 ),
85 key("target", Expr::String(self.target.clone())),
86 key(
87 "mode",
88 Expr::Symbol(Symbol::new(lease_mode_label(&self.mode))),
89 ),
90 ];
91 if let Some(repo) = &self.repo {
92 entries.push(key("repo", Expr::String(repo.clone())));
93 }
94 Expr::Map(entries)
95 }
96
97 fn repo_target(
98 kind: WorkspaceLeaseKind,
99 repo: impl Into<String>,
100 target: impl Into<String>,
101 ) -> Self {
102 Self {
103 kind,
104 repo: Some(repo.into()),
105 target: normalize_path(&target.into()),
106 mode: WorkspaceLeaseMode::ExclusiveWrite,
107 }
108 }
109
110 fn conflicts_with(&self, other: &Self) -> bool {
111 if self.mode == WorkspaceLeaseMode::SharedRead
112 && other.mode == WorkspaceLeaseMode::SharedRead
113 {
114 return false;
115 }
116 leases_overlap(self, other)
117 }
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct WorkspaceLeaseConflict {
123 pub left_mission: Symbol,
125 pub left: WorkspaceLease,
127 pub right_mission: Symbol,
129 pub right: WorkspaceLease,
131}
132
133pub fn detect_workspace_lease_conflicts(missions: &[AgentMission]) -> Vec<WorkspaceLeaseConflict> {
135 let mut conflicts = Vec::new();
136 for (left_index, left_mission) in missions.iter().enumerate() {
137 for right_mission in missions.iter().skip(left_index + 1) {
138 for left in left_mission.leases() {
139 for right in right_mission.leases() {
140 if left.conflicts_with(right) {
141 conflicts.push(WorkspaceLeaseConflict {
142 left_mission: left_mission.id().clone(),
143 left: left.clone(),
144 right_mission: right_mission.id().clone(),
145 right: right.clone(),
146 });
147 }
148 }
149 }
150 }
151 }
152 conflicts
153}
154
155pub(crate) fn leases_expr(leases: &[WorkspaceLease]) -> Expr {
156 Expr::List(leases.iter().map(WorkspaceLease::as_expr).collect())
157}
158
159fn leases_overlap(left: &WorkspaceLease, right: &WorkspaceLease) -> bool {
160 if left.kind == WorkspaceLeaseKind::IdeObject || right.kind == WorkspaceLeaseKind::IdeObject {
161 return left.kind == right.kind && left.target == right.target;
162 }
163 if left.repo != right.repo {
164 return false;
165 }
166 match (&left.kind, &right.kind) {
167 (WorkspaceLeaseKind::Crate, WorkspaceLeaseKind::Crate) => left.target == right.target,
168 (WorkspaceLeaseKind::File, WorkspaceLeaseKind::File) => left.target == right.target,
169 (WorkspaceLeaseKind::Directory, WorkspaceLeaseKind::Directory) => {
170 path_contains(&left.target, &right.target) || path_contains(&right.target, &left.target)
171 }
172 (WorkspaceLeaseKind::Directory, WorkspaceLeaseKind::File) => {
173 path_contains(&left.target, &right.target)
174 }
175 (WorkspaceLeaseKind::File, WorkspaceLeaseKind::Directory) => {
176 path_contains(&right.target, &left.target)
177 }
178 _ => false,
179 }
180}
181
182fn path_contains(directory: &str, path: &str) -> bool {
183 directory == "."
184 || directory == path
185 || path
186 .strip_prefix(directory)
187 .is_some_and(|rest| rest.starts_with('/'))
188}
189
190fn normalize_path(path: &str) -> String {
191 let normalized = path.trim_matches('/').replace('\\', "/");
192 if normalized.is_empty() {
193 ".".to_owned()
194 } else {
195 normalized
196 }
197}
198
199fn lease_kind_label(kind: &WorkspaceLeaseKind) -> &'static str {
200 match kind {
201 WorkspaceLeaseKind::File => "file",
202 WorkspaceLeaseKind::Directory => "directory",
203 WorkspaceLeaseKind::Crate => "crate",
204 WorkspaceLeaseKind::IdeObject => "ide-object",
205 }
206}
207
208fn lease_mode_label(mode: &WorkspaceLeaseMode) -> &'static str {
209 match mode {
210 WorkspaceLeaseMode::SharedRead => "shared-read",
211 WorkspaceLeaseMode::ExclusiveWrite => "exclusive-write",
212 }
213}