Coverage Report

Created: 2026-03-25 23:22

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/tenet/tenet/tenet-aws/src/services/spec.rs
Line
Count
Source
1
use std::{collections::HashMap, sync::Arc};
2
3
use crate::{
4
    ApiError,
5
    db::{
6
        TenetDatabase,
7
        graph::GraphRepository,
8
        project::ProjectRepository,
9
        spec::SpecRepository,
10
        traits::{ProjectRepositoryTrait, SpecRepositoryTrait},
11
    },
12
    models::{
13
        graph::{NodeId, NodeRef},
14
        organisation::OrganisationId,
15
        project::ProjectId,
16
        spec::{
17
            ActiveSpecContextEntry, ActiveSpecTaskContext, CreateSpecLinkRequest,
18
            CreateSpecRequest, CreateTaskLinkRequest, CreateTaskRequest, ProjectSpecSummary, Spec,
19
            SpecDetailView, SpecId, SpecLinkRelation, SpecLinkView, SpecOverlapWarning, SpecStatus,
20
            SpecSummaryView, Task, TaskDetailView, TaskId, TaskLinkRelation, TaskLinkView,
21
            TaskStatus, TaskSummary, UpdateSpecRequest, UpdateTaskRequest,
22
        },
23
    },
24
};
25
26
#[derive(Clone)]
27
pub struct SpecService {
28
    spec_repo: Arc<dyn SpecRepositoryTrait>,
29
    project_repo: Arc<dyn ProjectRepositoryTrait>,
30
    graph_repo: GraphRepository,
31
}
32
33
impl SpecService {
34
6
    pub fn new(database: TenetDatabase) -> Self {
35
6
        Self {
36
6
            spec_repo: Arc::new(SpecRepository::new(database.clone())),
37
6
            project_repo: Arc::new(ProjectRepository::new(database.clone())),
38
6
            graph_repo: GraphRepository::new(database),
39
6
        }
40
6
    }
41
42
    #[cfg(test)]
43
26
    pub fn new_with_repos(
44
26
        spec_repo: Arc<dyn SpecRepositoryTrait>,
45
26
        project_repo: Arc<dyn ProjectRepositoryTrait>,
46
26
        graph_repo: GraphRepository,
47
26
    ) -> Self {
48
26
        Self {
49
26
            spec_repo,
50
26
            project_repo,
51
26
            graph_repo,
52
26
        }
53
26
    }
54
55
8
    async fn verify_project_exists(
56
8
        &self,
57
8
        org_id: &OrganisationId,
58
8
        project_id: &ProjectId,
59
8
    ) -> Result<(), ApiError> {
60
8
        if !self.project_repo.project_exists(org_id, project_id).await
?0
{
61
0
            return Err(ApiError::NotFound(format!(
62
0
                "Project {} not found",
63
0
                project_id
64
0
            )));
65
8
        }
66
8
        Ok(())
67
8
    }
68
69
0
    fn validate_name(label: &str, name: &str) -> Result<(), ApiError> {
70
0
        let trimmed = name.trim();
71
0
        if trimmed.is_empty() {
72
0
            return Err(ApiError::InvalidRequest(format!(
73
0
                "{label} name cannot be empty"
74
0
            )));
75
0
        }
76
0
        if trimmed
77
0
            .split('/')
78
0
            .any(|segment| segment.is_empty() || !is_kebab_case_segment(segment))
79
        {
80
0
            return Err(ApiError::InvalidRequest(format!(
81
0
                "{label} name must use kebab-case path segments separated by '/'"
82
0
            )));
83
0
        }
84
0
        Ok(())
85
0
    }
86
87
0
    fn normalize_optional_text(value: Option<String>) -> Option<String> {
88
0
        value.and_then(|value| {
89
0
            let trimmed = value.trim();
90
0
            if trimmed.is_empty() {
91
0
                None
92
            } else {
93
0
                Some(trimmed.to_string())
94
            }
95
0
        })
96
0
    }
97
98
0
    pub async fn create_spec(
99
0
        &self,
100
0
        org_id: &OrganisationId,
101
0
        project_id: &ProjectId,
102
0
        request: &CreateSpecRequest,
103
0
    ) -> Result<Spec, ApiError> {
104
0
        self.verify_project_exists(org_id, project_id).await?;
105
0
        Self::validate_name("Spec", &request.name)?;
106
0
        if request.title.trim().is_empty() {
107
0
            return Err(ApiError::InvalidRequest(
108
0
                "Spec title cannot be empty".to_string(),
109
0
            ));
110
0
        }
111
0
        if request.owner.trim().is_empty() {
112
0
            return Err(ApiError::InvalidRequest(
113
0
                "Spec owner cannot be empty".to_string(),
114
0
            ));
115
0
        }
116
0
        if self
117
0
            .spec_repo
118
0
            .get_spec_by_name(project_id, &request.name)
119
0
            .await?
120
0
            .is_some()
121
        {
122
0
            return Err(ApiError::Conflict(format!(
123
0
                "Spec '{}' already exists",
124
0
                request.name
125
0
            )));
126
0
        }
127
128
0
        let spec = Spec::new(
129
0
            org_id.clone(),
130
0
            project_id.clone(),
131
0
            request.name.trim().to_string(),
132
0
            request.title.trim().to_string(),
133
0
            request.spec_type,
134
0
            request.status.unwrap_or(SpecStatus::Draft),
135
0
            request.owner.trim().to_string(),
136
0
            request.description.trim().to_string(),
137
0
            request.content.clone(),
138
        );
139
0
        self.spec_repo.create_spec(&spec).await?;
140
0
        Ok(spec)
141
0
    }
142
143
4
    pub async fn list_specs(
144
4
        &self,
145
4
        org_id: &OrganisationId,
146
4
        project_id: &ProjectId,
147
4
        status: Option<SpecStatus>,
148
4
    ) -> Result<Vec<SpecSummaryView>, ApiError> {
149
4
        self.verify_project_exists(org_id, project_id).await
?0
;
150
4
        let specs = self.spec_repo.list_specs(project_id).await
?0
;
151
4
        let mut filtered = Vec::new();
152
4
        for 
spec0
in specs {
153
0
            if status.is_some_and(|expected| spec.status != expected) {
154
0
                continue;
155
0
            }
156
0
            let tasks = self.spec_repo.list_tasks(&spec.id).await?;
157
0
            filtered.push(SpecSummaryView {
158
0
                task_summary: TaskSummary::from_tasks(&tasks),
159
0
                spec,
160
0
            });
161
        }
162
4
        Ok(filtered)
163
4
    }
164
165
0
    pub async fn get_spec(
166
0
        &self,
167
0
        org_id: &OrganisationId,
168
0
        project_id: &ProjectId,
169
0
        spec_id: &SpecId,
170
0
    ) -> Result<SpecDetailView, ApiError> {
171
0
        let spec = self
172
0
            .spec_repo
173
0
            .get_spec(org_id, project_id, spec_id)
174
0
            .await?
175
0
            .ok_or_else(|| ApiError::NotFound(format!("Spec {} not found", spec_id)))?;
176
0
        let tasks = self.spec_repo.list_tasks(spec_id).await?;
177
0
        let links = self.spec_repo.list_spec_links(org_id, spec_id).await?;
178
0
        let overlap_warnings = self
179
0
            .build_overlap_warnings(org_id, project_id, &spec, &links)
180
0
            .await?;
181
0
        Ok(SpecDetailView {
182
0
            task_summary: TaskSummary::from_tasks(&tasks),
183
0
            spec,
184
0
            tasks,
185
0
            links,
186
0
            overlap_warnings,
187
0
        })
188
0
    }
189
190
0
    pub async fn update_spec(
191
0
        &self,
192
0
        org_id: &OrganisationId,
193
0
        project_id: &ProjectId,
194
0
        spec_id: &SpecId,
195
0
        request: UpdateSpecRequest,
196
0
    ) -> Result<Spec, ApiError> {
197
0
        self.verify_project_exists(org_id, project_id).await?;
198
0
        if let Some(ref name) = request.new_name {
199
0
            Self::validate_name("Spec", name)?;
200
0
            if let Some(existing) = self.spec_repo.get_spec_by_name(project_id, name).await?
201
0
                && existing.id != *spec_id
202
            {
203
0
                return Err(ApiError::Conflict(format!(
204
0
                    "Spec '{}' already exists",
205
0
                    name
206
0
                )));
207
0
            }
208
0
        }
209
0
        self.spec_repo
210
0
            .update_spec(org_id, project_id, spec_id, request)
211
0
            .await?
212
0
            .ok_or_else(|| ApiError::NotFound(format!("Spec {} not found", spec_id)))
213
0
    }
214
215
0
    pub async fn delete_spec(
216
0
        &self,
217
0
        org_id: &OrganisationId,
218
0
        project_id: &ProjectId,
219
0
        spec_id: &SpecId,
220
0
    ) -> Result<(), ApiError> {
221
0
        self.verify_project_exists(org_id, project_id).await?;
222
0
        self.spec_repo
223
0
            .delete_spec(org_id, project_id, spec_id)
224
0
            .await
225
0
    }
226
227
0
    pub async fn create_task(
228
0
        &self,
229
0
        org_id: &OrganisationId,
230
0
        project_id: &ProjectId,
231
0
        spec_id: &SpecId,
232
0
        request: &CreateTaskRequest,
233
0
    ) -> Result<Task, ApiError> {
234
0
        self.verify_project_exists(org_id, project_id).await?;
235
0
        let Some(_spec) = self.spec_repo.get_spec(org_id, project_id, spec_id).await? else {
236
0
            return Err(ApiError::NotFound(format!("Spec {} not found", spec_id)));
237
        };
238
0
        Self::validate_name("Task", &request.name)?;
239
0
        if request.title.trim().is_empty() {
240
0
            return Err(ApiError::InvalidRequest(
241
0
                "Task title cannot be empty".to_string(),
242
0
            ));
243
0
        }
244
0
        if self
245
0
            .spec_repo
246
0
            .get_task_by_name(spec_id, &request.name)
247
0
            .await?
248
0
            .is_some()
249
        {
250
0
            return Err(ApiError::Conflict(format!(
251
0
                "Task '{}' already exists in this spec",
252
0
                request.name
253
0
            )));
254
0
        }
255
256
0
        let sort_order = match request.sort_order {
257
0
            Some(value) => value,
258
            None => {
259
0
                self.spec_repo
260
0
                    .list_tasks(spec_id)
261
0
                    .await?
262
0
                    .into_iter()
263
0
                    .map(|task| task.sort_order)
264
0
                    .max()
265
0
                    .unwrap_or(-1)
266
                    + 1
267
            }
268
        };
269
270
0
        let task = Task::new(
271
0
            org_id.clone(),
272
0
            project_id.clone(),
273
0
            spec_id.clone(),
274
0
            request.name.trim().to_string(),
275
0
            request.title.trim().to_string(),
276
0
            request.executor_type,
277
0
            request.status.unwrap_or(TaskStatus::Todo),
278
0
            sort_order,
279
0
            request.description.trim().to_string(),
280
0
            Self::normalize_optional_text(request.acceptance_criteria.clone()),
281
0
            Self::normalize_optional_text(request.effort_estimate.clone()),
282
        );
283
0
        self.spec_repo.create_task(&task).await?;
284
0
        Ok(task)
285
0
    }
286
287
0
    pub async fn list_tasks(
288
0
        &self,
289
0
        org_id: &OrganisationId,
290
0
        project_id: &ProjectId,
291
0
        spec_id: &SpecId,
292
0
    ) -> Result<Vec<Task>, ApiError> {
293
0
        self.verify_project_exists(org_id, project_id).await?;
294
0
        if self
295
0
            .spec_repo
296
0
            .get_spec(org_id, project_id, spec_id)
297
0
            .await?
298
0
            .is_none()
299
        {
300
0
            return Err(ApiError::NotFound(format!("Spec {} not found", spec_id)));
301
0
        }
302
0
        self.spec_repo.list_tasks(spec_id).await
303
0
    }
304
305
0
    pub async fn get_task(
306
0
        &self,
307
0
        org_id: &OrganisationId,
308
0
        project_id: &ProjectId,
309
0
        spec_id: &SpecId,
310
0
        task_id: &TaskId,
311
0
    ) -> Result<TaskDetailView, ApiError> {
312
0
        let task = self
313
0
            .spec_repo
314
0
            .get_task(org_id, project_id, spec_id, task_id)
315
0
            .await?
316
0
            .ok_or_else(|| ApiError::NotFound(format!("Task {} not found", task_id)))?;
317
0
        let links = self.spec_repo.list_task_links(org_id, task_id).await?;
318
0
        Ok(TaskDetailView { task, links })
319
0
    }
320
321
0
    pub async fn update_task(
322
0
        &self,
323
0
        org_id: &OrganisationId,
324
0
        project_id: &ProjectId,
325
0
        spec_id: &SpecId,
326
0
        task_id: &TaskId,
327
0
        request: UpdateTaskRequest,
328
0
    ) -> Result<Task, ApiError> {
329
0
        self.verify_project_exists(org_id, project_id).await?;
330
0
        if let Some(ref name) = request.new_name {
331
0
            Self::validate_name("Task", name)?;
332
0
            if let Some(existing) = self.spec_repo.get_task_by_name(spec_id, name).await?
333
0
                && existing.id != *task_id
334
            {
335
0
                return Err(ApiError::Conflict(format!(
336
0
                    "Task '{}' already exists in this spec",
337
0
                    name
338
0
                )));
339
0
            }
340
0
        }
341
0
        self.spec_repo
342
0
            .update_task(org_id, project_id, spec_id, task_id, request)
343
0
            .await?
344
0
            .ok_or_else(|| ApiError::NotFound(format!("Task {} not found", task_id)))
345
0
    }
346
347
0
    pub async fn delete_task(
348
0
        &self,
349
0
        org_id: &OrganisationId,
350
0
        project_id: &ProjectId,
351
0
        spec_id: &SpecId,
352
0
        task_id: &TaskId,
353
0
    ) -> Result<(), ApiError> {
354
0
        self.verify_project_exists(org_id, project_id).await?;
355
0
        self.spec_repo
356
0
            .delete_task(org_id, project_id, spec_id, task_id)
357
0
            .await
358
0
    }
359
360
0
    pub async fn link_spec(
361
0
        &self,
362
0
        org_id: &OrganisationId,
363
0
        project_id: &ProjectId,
364
0
        spec_id: &SpecId,
365
0
        request: &CreateSpecLinkRequest,
366
0
    ) -> Result<SpecLinkView, ApiError> {
367
0
        self.verify_project_exists(org_id, project_id).await?;
368
0
        self.assert_target_exists(org_id, &request.target_id)
369
0
            .await?;
370
0
        self.spec_repo
371
0
            .link_spec_to_target(
372
0
                org_id,
373
0
                project_id,
374
0
                spec_id,
375
0
                &request.target_id,
376
0
                request.target_kind,
377
0
                request.relation,
378
0
            )
379
0
            .await
380
0
    }
381
382
0
    pub async fn unlink_spec(
383
0
        &self,
384
0
        org_id: &OrganisationId,
385
0
        project_id: &ProjectId,
386
0
        spec_id: &SpecId,
387
0
        edge_id: &str,
388
0
    ) -> Result<(), ApiError> {
389
0
        self.verify_project_exists(org_id, project_id).await?;
390
0
        self.spec_repo
391
0
            .unlink_spec_from_target(org_id, spec_id, edge_id)
392
0
            .await
393
0
    }
394
395
0
    pub async fn link_task(
396
0
        &self,
397
0
        org_id: &OrganisationId,
398
0
        project_id: &ProjectId,
399
0
        task_id: &TaskId,
400
0
        request: &CreateTaskLinkRequest,
401
0
    ) -> Result<TaskLinkView, ApiError> {
402
0
        self.verify_project_exists(org_id, project_id).await?;
403
0
        self.assert_target_exists(org_id, &request.target_id)
404
0
            .await?;
405
0
        if matches!(request.relation, TaskLinkRelation::DependsOn)
406
0
            && request.target_kind != crate::models::spec::SpecTaskTargetKind::Task
407
        {
408
0
            return Err(ApiError::InvalidRequest(
409
0
                "Task dependency links must target another task".to_string(),
410
0
            ));
411
0
        }
412
0
        self.spec_repo
413
0
            .link_task_to_target(org_id, project_id, task_id, request)
414
0
            .await
415
0
    }
416
417
0
    pub async fn unlink_task(
418
0
        &self,
419
0
        org_id: &OrganisationId,
420
0
        project_id: &ProjectId,
421
0
        task_id: &TaskId,
422
0
        edge_id: &str,
423
0
    ) -> Result<(), ApiError> {
424
0
        self.verify_project_exists(org_id, project_id).await?;
425
0
        self.spec_repo
426
0
            .unlink_task_from_target(org_id, task_id, edge_id)
427
0
            .await
428
0
    }
429
430
4
    pub async fn project_summary(
431
4
        &self,
432
4
        org_id: &OrganisationId,
433
4
        project_id: &ProjectId,
434
4
    ) -> Result<ProjectSpecSummary, ApiError> {
435
4
        self.verify_project_exists(org_id, project_id).await
?0
;
436
4
        let specs = self.spec_repo.list_specs(project_id).await
?0
;
437
4
        let mut summary = ProjectSpecSummary {
438
4
            total: specs.len() as i32,
439
4
            draft: 0,
440
4
            active: 0,
441
4
            done: 0,
442
4
        };
443
4
        for 
spec0
in specs {
444
0
            match spec.status {
445
0
                SpecStatus::Draft => summary.draft += 1,
446
0
                SpecStatus::Active => summary.active += 1,
447
0
                SpecStatus::Done => summary.done += 1,
448
            }
449
        }
450
4
        Ok(summary)
451
4
    }
452
453
0
    pub async fn active_specs_for_targets(
454
0
        &self,
455
0
        org_id: &OrganisationId,
456
0
        project_id: &ProjectId,
457
0
        target_ids: &[String],
458
0
    ) -> Result<Vec<ActiveSpecContextEntry>, ApiError> {
459
0
        let specs = self
460
0
            .spec_repo
461
0
            .list_specs(project_id)
462
0
            .await?
463
0
            .into_iter()
464
0
            .filter(|spec| spec.status == SpecStatus::Active)
465
0
            .collect::<Vec<_>>();
466
0
        let spec_by_id = specs
467
0
            .into_iter()
468
0
            .map(|spec| (spec.id.clone(), spec))
469
0
            .collect::<HashMap<_, _>>();
470
0
        let links = self
471
0
            .spec_repo
472
0
            .list_spec_links_by_project(project_id)
473
0
            .await?;
474
0
        let task_links = self
475
0
            .spec_repo
476
0
            .list_task_links_by_project(project_id)
477
0
            .await?;
478
0
        let mut tasks_by_spec: HashMap<SpecId, Vec<ActiveSpecTaskContext>> = HashMap::new();
479
0
        let tasks =
480
0
            spec_by_id
481
0
                .keys()
482
0
                .map(|spec_id| async move {
483
0
                    (spec_id.clone(), self.spec_repo.list_tasks(spec_id).await)
484
0
                })
485
0
                .collect::<Vec<_>>();
486
0
        for future in tasks {
487
0
            let (spec_id, result) = future.await;
488
0
            for task in result? {
489
0
                tasks_by_spec
490
0
                    .entry(spec_id.clone())
491
0
                    .or_default()
492
0
                    .push(ActiveSpecTaskContext {
493
0
                        id: task.id,
494
0
                        name: task.name,
495
0
                        title: task.title,
496
0
                        executor_type: task.executor_type,
497
0
                        status: task.status,
498
0
                        sort_order: task.sort_order,
499
0
                    });
500
0
            }
501
        }
502
503
0
        let mut task_ids_by_target: HashMap<String, Vec<TaskId>> = HashMap::new();
504
0
        for (task_id, link) in task_links {
505
0
            if matches!(link.relation, TaskLinkRelation::Modifies)
506
0
                && target_ids.contains(&link.target_id)
507
0
            {
508
0
                task_ids_by_target
509
0
                    .entry(link.target_id)
510
0
                    .or_default()
511
0
                    .push(task_id);
512
0
            }
513
        }
514
515
0
        let mut entries = Vec::new();
516
0
        for (spec_id, link) in links {
517
0
            if !target_ids.contains(&link.target_id) {
518
0
                continue;
519
0
            }
520
0
            if !matches!(
521
0
                link.relation,
522
                SpecLinkRelation::Affects | SpecLinkRelation::ConstrainedBy
523
            ) {
524
0
                continue;
525
0
            }
526
0
            let Some(spec) = spec_by_id.get(&spec_id) else {
527
0
                continue;
528
            };
529
0
            let all_tasks = tasks_by_spec.get(&spec_id).cloned().unwrap_or_default();
530
0
            let linked_task_ids = task_ids_by_target
531
0
                .get(&link.target_id)
532
0
                .cloned()
533
0
                .unwrap_or_default();
534
0
            let mut relevant_tasks = all_tasks
535
0
                .into_iter()
536
0
                .filter(|task| linked_task_ids.iter().any(|task_id| task.id == *task_id))
537
0
                .collect::<Vec<_>>();
538
0
            relevant_tasks.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
539
0
            entries.push(ActiveSpecContextEntry {
540
0
                spec_id: spec.id.clone(),
541
0
                name: spec.name.clone(),
542
0
                title: spec.title.clone(),
543
0
                spec_type: spec.spec_type,
544
0
                status: spec.status,
545
0
                relation: link.relation,
546
0
                tasks: relevant_tasks,
547
0
            });
548
        }
549
550
0
        entries.sort_by(|a, b| a.name.cmp(&b.name));
551
0
        entries.dedup_by(|a, b| a.spec_id == b.spec_id && a.relation == b.relation);
552
0
        let _ = org_id;
553
0
        Ok(entries)
554
0
    }
555
556
0
    async fn assert_target_exists(
557
0
        &self,
558
0
        org_id: &OrganisationId,
559
0
        target_id: &str,
560
0
    ) -> Result<(), ApiError> {
561
0
        let node_id = NodeId::try_from(target_id.to_string())?;
562
0
        let found = self
563
0
            .graph_repo
564
0
            .get_node(&NodeRef {
565
0
                organisation_id: org_id.clone(),
566
0
                node_id,
567
0
            })
568
0
            .await?;
569
0
        if found.is_none() {
570
0
            return Err(ApiError::NotFound(format!(
571
0
                "Target node {} not found",
572
0
                target_id
573
0
            )));
574
0
        }
575
0
        Ok(())
576
0
    }
577
578
0
    async fn build_overlap_warnings(
579
0
        &self,
580
0
        org_id: &OrganisationId,
581
0
        project_id: &ProjectId,
582
0
        current_spec: &Spec,
583
0
        links: &[SpecLinkView],
584
0
    ) -> Result<Vec<SpecOverlapWarning>, ApiError> {
585
0
        let other_specs = self
586
0
            .spec_repo
587
0
            .list_specs(project_id)
588
0
            .await?
589
0
            .into_iter()
590
0
            .filter(|spec| spec.status == SpecStatus::Active && spec.id != current_spec.id)
591
0
            .collect::<Vec<_>>();
592
0
        if other_specs.is_empty() {
593
0
            return Ok(vec![]);
594
0
        }
595
596
0
        let other_spec_by_id = other_specs
597
0
            .into_iter()
598
0
            .map(|spec| (spec.id.clone(), spec))
599
0
            .collect::<HashMap<_, _>>();
600
0
        let other_links = self
601
0
            .spec_repo
602
0
            .list_spec_links_by_project(project_id)
603
0
            .await?;
604
0
        let mut warnings = Vec::new();
605
0
        for current_link in links
606
0
            .iter()
607
0
            .filter(|link| matches!(link.relation, SpecLinkRelation::Affects))
608
        {
609
0
            for (spec_id, link) in &other_links {
610
0
                if current_link.target_id != link.target_id
611
0
                    || !matches!(link.relation, SpecLinkRelation::Affects)
612
                {
613
0
                    continue;
614
0
                }
615
0
                let Some(overlapping_spec) = other_spec_by_id.get(spec_id) else {
616
0
                    continue;
617
                };
618
0
                warnings.push(SpecOverlapWarning {
619
0
                    target_id: current_link.target_id.clone(),
620
0
                    target_name: current_link.target_name.clone(),
621
0
                    overlapping_spec_id: overlapping_spec.id.clone(),
622
0
                    overlapping_spec_name: overlapping_spec.name.clone(),
623
0
                    overlapping_spec_title: overlapping_spec.title.clone(),
624
0
                });
625
            }
626
        }
627
0
        let _ = org_id;
628
0
        Ok(warnings)
629
0
    }
630
}
631
632
0
fn is_kebab_case_segment(segment: &str) -> bool {
633
0
    segment
634
0
        .chars()
635
0
        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
636
0
}