From 78f86f9d44cd9818e9f0a09e97404b55a4a109aa Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sat, 28 Feb 2026 18:32:12 -0700 Subject: [PATCH] feat: `searchDocuments` and `searchProjects` queries with CLI subcommands Add full-text search for documents and projects, extending the existing searchIssues pattern: - Add searchDocuments/searchProjects to operations.toml and run codegen - Add blocking client wrappers for both search operations - Implement `documents search [-l N] [--team KEY]` CLI subcommand - Implement `projects search [-l N] [--team KEY]` CLI subcommand - Update usage reference with both new commands - Add 4 offline tests + update 2 existing subcommand tests - Add 3 online SDK tests (basic connection + create-then-find) - Add 5 online CLI tests (basic JSON + create-then-find + team filter) --- crates/lineark-sdk/src/blocking_client.rs | 42 +++ .../lineark-sdk/src/generated/client_impl.rs | 15 + crates/lineark-sdk/src/generated/queries.rs | 236 ++++++++++++++ crates/lineark-sdk/tests/online.rs | 100 ++++++ crates/lineark/src/commands/documents.rs | 70 ++++- crates/lineark/src/commands/projects.rs | 76 ++++- crates/lineark/src/commands/usage.rs | 4 + crates/lineark/tests/offline.rs | 44 +++ crates/lineark/tests/online.rs | 288 +++++++++++++++++- schema/operations.toml | 2 + 10 files changed, 872 insertions(+), 5 deletions(-) diff --git a/crates/lineark-sdk/src/blocking_client.rs b/crates/lineark-sdk/src/blocking_client.rs index b81262e..ee9171c 100644 --- a/crates/lineark-sdk/src/blocking_client.rs +++ b/crates/lineark-sdk/src/blocking_client.rs @@ -247,6 +247,20 @@ blocking_query_builder! { methods = [before(impl Into), after(impl Into), first(i64), last(i64), include_archived(bool), include_comments(bool), team_id(impl Into)] } +blocking_query_builder! { + query_type = SearchDocumentsQueryBuilder, + node_type = DocumentSearchResult, + return_type = Connection, + methods = [before(impl Into), after(impl Into), first(i64), last(i64), include_archived(bool), include_comments(bool), team_id(impl Into)] +} + +blocking_query_builder! { + query_type = SearchProjectsQueryBuilder, + node_type = ProjectSearchResult, + return_type = Connection, + methods = [before(impl Into), after(impl Into), first(i64), last(i64), include_archived(bool), include_comments(bool), team_id(impl Into)] +} + blocking_query_builder! { query_type = DocumentsQueryBuilder, node_type = Document, @@ -357,6 +371,34 @@ impl Client { ) } + /// Search documents (blocking). + pub fn search_documents( + &self, + term: impl Into, + ) -> BlockingQuery< + '_, + crate::generated::queries::SearchDocumentsQueryBuilder<'_, DocumentSearchResult>, + > { + BlockingQuery::new( + self.inner.search_documents::(term), + &self.rt, + ) + } + + /// Search projects (blocking). + pub fn search_projects( + &self, + term: impl Into, + ) -> BlockingQuery< + '_, + crate::generated::queries::SearchProjectsQueryBuilder<'_, ProjectSearchResult>, + > { + BlockingQuery::new( + self.inner.search_projects::(term), + &self.rt, + ) + } + /// List documents (blocking). pub fn documents( &self, diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..7957405 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -63,6 +63,21 @@ impl Client { ) -> Result { crate::generated::queries::team::(self, id).await } + /// Search documents. + /// + /// Full type: [`DocumentSearchResult`](super::types::DocumentSearchResult) + pub fn search_documents( + &self, + term: impl Into, + ) -> SearchDocumentsQueryBuilder<'_, T> { + crate::generated::queries::search_documents(self, term) + } + /// Search projects. + /// + /// Full type: [`ProjectSearchResult`](super::types::ProjectSearchResult) + pub fn search_projects(&self, term: impl Into) -> SearchProjectsQueryBuilder<'_, T> { + crate::generated::queries::search_projects(self, term) + } /// Search issues. /// /// Full type: [`IssueSearchResult`](super::types::IssueSearchResult) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..064e9d8 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -384,6 +384,200 @@ impl<'a, T: DeserializeOwned + GraphQLFields> .await } } +/// Query builder: Search documents. +/// +/// Full type: [`DocumentSearchResult`](super::types::DocumentSearchResult) +/// +/// Use setter methods to configure optional parameters, then call +/// [`.send()`](Self::send) to execute the query. +#[must_use] +pub struct SearchDocumentsQueryBuilder<'a, T> { + client: &'a Client, + term: String, + before: Option, + after: Option, + first: Option, + last: Option, + include_archived: Option, + order_by: Option, + include_comments: Option, + team_id: Option, + _marker: std::marker::PhantomData, +} +impl<'a, T: DeserializeOwned + GraphQLFields> + SearchDocumentsQueryBuilder<'a, T> +{ + pub fn before(mut self, value: impl Into) -> Self { + self.before = Some(value.into()); + self + } + pub fn after(mut self, value: impl Into) -> Self { + self.after = Some(value.into()); + self + } + pub fn first(mut self, value: i64) -> Self { + self.first = Some(value); + self + } + pub fn last(mut self, value: i64) -> Self { + self.last = Some(value); + self + } + pub fn include_archived(mut self, value: bool) -> Self { + self.include_archived = Some(value); + self + } + pub fn order_by(mut self, value: PaginationOrderBy) -> Self { + self.order_by = Some(value); + self + } + pub fn include_comments(mut self, value: bool) -> Self { + self.include_comments = Some(value); + self + } + pub fn team_id(mut self, value: impl Into) -> Self { + self.team_id = Some(value.into()); + self + } + pub async fn send(self) -> Result, LinearError> { + let mut map = serde_json::Map::new(); + map.insert("term".to_string(), serde_json::json!(self.term)); + if let Some(ref v) = self.before { + map.insert("before".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.after { + map.insert("after".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.first { + map.insert("first".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.last { + map.insert("last".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.include_archived { + map.insert("includeArchived".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.order_by { + map.insert("orderBy".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.include_comments { + map.insert("includeComments".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.team_id { + map.insert("teamId".to_string(), serde_json::json!(v)); + } + let variables = serde_json::Value::Object(map); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}", + "SearchDocuments", + "$before: String, $after: String, $first: Int, $last: Int, $includeArchived: Boolean, $orderBy: PaginationOrderBy, $term: String!, $includeComments: Boolean, $teamId: String", + "searchDocuments", + "before: $before, after: $after, first: $first, last: $last, includeArchived: $includeArchived, orderBy: $orderBy, term: $term, includeComments: $includeComments, teamId: $teamId", + selection + ); + self.client + .execute_connection::(&query, variables, "searchDocuments") + .await + } +} +/// Query builder: Search projects. +/// +/// Full type: [`ProjectSearchResult`](super::types::ProjectSearchResult) +/// +/// Use setter methods to configure optional parameters, then call +/// [`.send()`](Self::send) to execute the query. +#[must_use] +pub struct SearchProjectsQueryBuilder<'a, T> { + client: &'a Client, + term: String, + before: Option, + after: Option, + first: Option, + last: Option, + include_archived: Option, + order_by: Option, + include_comments: Option, + team_id: Option, + _marker: std::marker::PhantomData, +} +impl<'a, T: DeserializeOwned + GraphQLFields> + SearchProjectsQueryBuilder<'a, T> +{ + pub fn before(mut self, value: impl Into) -> Self { + self.before = Some(value.into()); + self + } + pub fn after(mut self, value: impl Into) -> Self { + self.after = Some(value.into()); + self + } + pub fn first(mut self, value: i64) -> Self { + self.first = Some(value); + self + } + pub fn last(mut self, value: i64) -> Self { + self.last = Some(value); + self + } + pub fn include_archived(mut self, value: bool) -> Self { + self.include_archived = Some(value); + self + } + pub fn order_by(mut self, value: PaginationOrderBy) -> Self { + self.order_by = Some(value); + self + } + pub fn include_comments(mut self, value: bool) -> Self { + self.include_comments = Some(value); + self + } + pub fn team_id(mut self, value: impl Into) -> Self { + self.team_id = Some(value.into()); + self + } + pub async fn send(self) -> Result, LinearError> { + let mut map = serde_json::Map::new(); + map.insert("term".to_string(), serde_json::json!(self.term)); + if let Some(ref v) = self.before { + map.insert("before".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.after { + map.insert("after".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.first { + map.insert("first".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.last { + map.insert("last".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.include_archived { + map.insert("includeArchived".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.order_by { + map.insert("orderBy".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.include_comments { + map.insert("includeComments".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.team_id { + map.insert("teamId".to_string(), serde_json::json!(v)); + } + let variables = serde_json::Value::Object(map); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}", + "SearchProjects", + "$before: String, $after: String, $first: Int, $last: Int, $includeArchived: Boolean, $orderBy: PaginationOrderBy, $term: String!, $includeComments: Boolean, $teamId: String", + "searchProjects", + "before: $before, after: $after, first: $first, last: $last, includeArchived: $includeArchived, orderBy: $orderBy, term: $term, includeComments: $includeComments, teamId: $teamId", + selection + ); + self.client + .execute_connection::(&query, variables, "searchProjects") + .await + } +} /// Query builder: Search issues. /// /// Full type: [`IssueSearchResult`](super::types::IssueSearchResult) @@ -1119,6 +1313,48 @@ pub async fn team(&query, variables, "team").await } +/// Search documents. +/// +/// Full type: [`DocumentSearchResult`](super::types::DocumentSearchResult) +pub fn search_documents<'a, T>( + client: &'a Client, + term: impl Into, +) -> SearchDocumentsQueryBuilder<'a, T> { + SearchDocumentsQueryBuilder { + client, + term: term.into(), + before: None, + after: None, + first: None, + last: None, + include_archived: None, + order_by: None, + include_comments: None, + team_id: None, + _marker: std::marker::PhantomData, + } +} +/// Search projects. +/// +/// Full type: [`ProjectSearchResult`](super::types::ProjectSearchResult) +pub fn search_projects<'a, T>( + client: &'a Client, + term: impl Into, +) -> SearchProjectsQueryBuilder<'a, T> { + SearchProjectsQueryBuilder { + client, + term: term.into(), + before: None, + after: None, + first: None, + last: None, + include_archived: None, + order_by: None, + include_comments: None, + team_id: None, + _marker: std::marker::PhantomData, + } +} /// Search issues. /// /// Full type: [`IssueSearchResult`](super::types::IssueSearchResult) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..9c96599 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1061,6 +1061,106 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Search Documents ───────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn search_documents_returns_connection() { + let client = test_client(); + let conn = client + .search_documents::("test") + .first(5) + .send() + .await + .unwrap(); + for doc in &conn.nodes { + assert!(doc.id.is_some()); + } + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn search_projects_returns_connection() { + let client = test_client(); + let conn = client + .search_projects::("test") + .first(5) + .send() + .await + .unwrap(); + for proj in &conn.nodes { + assert!(proj.id.is_some()); + } + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn search_documents_finds_created_document() { + use lineark_sdk::generated::inputs::DocumentCreateInput; + + let client = test_client(); + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + // Create a document with a unique title. + let unique = format!("xdocsrch{}", uuid::Uuid::new_v4().simple()); + let input = DocumentCreateInput { + title: Some(unique.clone()), + content: Some("Search test document content.".to_string()), + team_id: Some(team_id), + ..Default::default() + }; + let doc = client.document_create::(input).await.unwrap(); + let doc_id = doc.id.clone().unwrap(); + let _doc_guard = DocumentGuard { + token: test_token(), + id: doc_id.clone(), + }; + + // Linear's search index is async — retry with backoff. + // Document search indexing can take longer than issue indexing. + let mut matched = false; + for i in 0..12 { + tokio::time::sleep(std::time::Duration::from_secs(if i < 3 { 1 } else { 3 })).await; + let found = match client + .search_documents::(&unique) + .first(5) + .send() + .await + { + Ok(v) => v, + Err(_) => continue, + }; + matched = found + .nodes + .iter() + .any(|n| n.title.as_deref().is_some_and(|t| t.contains(&unique))); + if matched { + break; + } + } + assert!( + matched, + "search_documents(term) should find the created document" + ); + + // Search for nonsense — should NOT find it. + let not_found = client + .search_documents::("xyzzy_nonexistent_99999") + .first(5) + .send() + .await + .expect("nonsense search should not be rate-limited"); + let false_match = not_found + .nodes + .iter() + .any(|n| n.title.as_deref().is_some_and(|t| t.contains(&unique))); + assert!( + !false_match, + "search with different term should not find our document" + ); + + // Clean up. + client.document_delete::(doc_id).await.unwrap(); + } + // ── Error handling ────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/documents.rs b/crates/lineark/src/commands/documents.rs index 0d071fe..9a780b5 100644 --- a/crates/lineark/src/commands/documents.rs +++ b/crates/lineark/src/commands/documents.rs @@ -1,11 +1,11 @@ use clap::Args; use lineark_sdk::generated::inputs::{DocumentCreateInput, DocumentFilter, DocumentUpdateInput}; -use lineark_sdk::generated::types::Document; +use lineark_sdk::generated::types::{Document, DocumentSearchResult}; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; use tabled::Tabled; -use super::helpers::{resolve_issue_id, resolve_project_id}; +use super::helpers::{resolve_issue_id, resolve_project_id, resolve_team_id}; use crate::output::{self, Format}; /// Manage documents. @@ -78,6 +78,22 @@ pub enum DocumentsAction { /// Document UUID. id: String, }, + /// Full-text search across document titles and content. + /// + /// Examples: + /// lineark documents search "onboarding" + /// lineark documents search "API design" --limit 10 + /// lineark documents search "spec" --team ENG + Search { + /// Search query text. + query: String, + /// Maximum number of results (max 250). + #[arg(short = 'l', long, default_value = "25", value_parser = clap::value_parser!(i64).range(1..=250))] + limit: i64, + /// Filter by team key, name, or UUID. + #[arg(long)] + team: Option, + }, } // ── Lean types ─────────────────────────────────────────────────────────────── @@ -104,6 +120,18 @@ struct DocumentRef { slug_id: Option, } +/// Lean search result type for `documents search`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = DocumentSearchResult)] +#[serde(rename_all = "camelCase", default)] +struct DocSearchSummary { + pub id: Option, + pub title: Option, + pub slug_id: Option, + pub url: Option, + pub updated_at: Option, +} + // ── List row ───────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Tabled)] @@ -125,6 +153,29 @@ impl From<&DocumentSummary> for DocumentRow { } } +// ── Search row ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Tabled)] +struct DocSearchRow { + id: String, + title: String, + slug_id: String, + url: String, + updated_at: String, +} + +impl From<&DocSearchSummary> for DocSearchRow { + fn from(d: &DocSearchSummary) -> Self { + Self { + id: d.id.clone().unwrap_or_default(), + title: d.title.clone().unwrap_or_default(), + slug_id: d.slug_id.clone().unwrap_or_default(), + url: d.url.clone().unwrap_or_default(), + updated_at: d.updated_at.clone().unwrap_or_default(), + } + } +} + // ── Command dispatch ───────────────────────────────────────────────────────── pub async fn run(cmd: DocumentsCmd, client: &Client, format: Format) -> anyhow::Result<()> { @@ -241,6 +292,21 @@ pub async fn run(cmd: DocumentsCmd, client: &Client, format: Format) -> anyhow:: output::print_one(&doc, format); } + DocumentsAction::Search { query, limit, team } => { + let mut builder = client + .search_documents::(query) + .first(limit); + + if let Some(ref team_key) = team { + let team_id = resolve_team_id(client, team_key).await?; + builder = builder.team_id(team_id); + } + + let conn = builder.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; + + let rows: Vec = conn.nodes.iter().map(DocSearchRow::from).collect(); + output::print_table(&rows, format); + } } Ok(()) } diff --git a/crates/lineark/src/commands/projects.rs b/crates/lineark/src/commands/projects.rs index d83f4eb..61fcdb2 100644 --- a/crates/lineark/src/commands/projects.rs +++ b/crates/lineark/src/commands/projects.rs @@ -1,14 +1,15 @@ use clap::Args; use lineark_sdk::generated::inputs::{ProjectCreateInput, ProjectFilter}; use lineark_sdk::generated::types::{ - Project, ProjectStatus, Team, TeamConnection, User, UserConnection, + Project, ProjectSearchResult, ProjectStatus, Team, TeamConnection, User, UserConnection, }; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; use tabled::Tabled; use super::helpers::{ - resolve_project_id, resolve_team_ids, resolve_user_id_or_me, resolve_user_ids_or_me, + resolve_project_id, resolve_team_id, resolve_team_ids, resolve_user_id_or_me, + resolve_user_ids_or_me, }; use crate::output::{self, Format}; @@ -41,6 +42,22 @@ pub enum ProjectsAction { /// Project name or UUID. id: String, }, + /// Full-text search across project names and descriptions. + /// + /// Examples: + /// lineark projects search "mobile app" + /// lineark projects search "Q4" --limit 10 + /// lineark projects search "infrastructure" --team ENG + Search { + /// Search query text. + query: String, + /// Maximum number of results (max 250). + #[arg(short = 'l', long, default_value = "25", value_parser = clap::value_parser!(i64).range(1..=250))] + limit: i64, + /// Filter by team key, name, or UUID. + #[arg(long)] + team: Option, + }, /// Create a new project. /// /// Examples: @@ -191,6 +208,46 @@ struct ProjectRef { slug_id: Option, } +// ── Search types ──────────────────────────────────────────────────────── + +/// Lean search result type for `projects search`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = ProjectSearchResult)] +#[serde(rename_all = "camelCase", default)] +struct ProjSearchSummary { + pub id: Option, + pub name: Option, + pub slug_id: Option, + pub url: Option, + #[graphql(nested)] + pub lead: Option, +} + +#[derive(Debug, Serialize, Tabled)] +struct ProjSearchRow { + id: String, + name: String, + slug_id: String, + lead: String, + url: String, +} + +impl From<&ProjSearchSummary> for ProjSearchRow { + fn from(p: &ProjSearchSummary) -> Self { + Self { + id: p.id.clone().unwrap_or_default(), + name: p.name.clone().unwrap_or_default(), + slug_id: p.slug_id.clone().unwrap_or_default(), + lead: p + .lead + .as_ref() + .and_then(|l| l.display_name.clone().or_else(|| l.name.clone())) + .unwrap_or_default(), + url: p.url.clone().unwrap_or_default(), + } + } +} + // ── Command dispatch ──────────────────────────────────────────────────── pub async fn run(cmd: ProjectsCmd, client: &Client, format: Format) -> anyhow::Result<()> { @@ -240,6 +297,21 @@ pub async fn run(cmd: ProjectsCmd, client: &Client, format: Format) -> anyhow::R .map_err(|e| anyhow::anyhow!("{}", e))?; output::print_one(&project, format); } + ProjectsAction::Search { query, limit, team } => { + let mut builder = client + .search_projects::(query) + .first(limit); + + if let Some(ref team_key) = team { + let team_id = resolve_team_id(client, team_key).await?; + builder = builder.team_id(team_id); + } + + let conn = builder.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; + + let rows: Vec = conn.nodes.iter().map(ProjSearchRow::from).collect(); + output::print_table(&rows, format); + } ProjectsAction::Create { name, team, diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..22b10d4 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -42,6 +42,8 @@ COMMANDS: lineark users list [--active] List users lineark projects list [--led-by-me] List all projects (with lead) lineark projects read Full project detail (lead, members, status, dates, teams) + lineark projects search [-l N] Full-text search projects + [--team KEY] Filter by team lineark projects create --team KEY[,KEY] Create a new project [--description TEXT] [--lead NAME-OR-ID|me] Description, project lead [--members NAME,...|me] Project members (comma-separated) @@ -87,6 +89,8 @@ COMMANDS: lineark documents list [--limit N] List documents (lean output) [--project NAME-OR-ID] [--issue ID] Filter by project or issue lineark documents read Read document (includes content) + lineark documents search [-l N] Full-text search documents + [--team KEY] Filter by team lineark documents create --title TEXT Create a document [--content TEXT] [--project NAME-OR-ID] Project name or UUID [--issue ID] diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..2d0ee43 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -187,6 +187,7 @@ fn documents_help_shows_subcommands() { .success() .stdout(predicate::str::contains("list")) .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("search")) .stdout(predicate::str::contains("create")) .stdout(predicate::str::contains("update")) .stdout(predicate::str::contains("delete")); @@ -392,6 +393,7 @@ fn projects_help_shows_subcommands() { .success() .stdout(predicate::str::contains("list")) .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("search")) .stdout(predicate::str::contains("create")); } @@ -887,3 +889,45 @@ fn usage_includes_comments_delete() { .success() .stdout(predicate::str::contains("comments delete")); } + +// ── Documents search ──────────────────────────────────────────────────────── + +#[test] +fn documents_search_help_shows_flags() { + lineark() + .args(["documents", "search", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--team")) + .stdout(predicate::str::contains("--limit")); +} + +#[test] +fn usage_includes_documents_search() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("documents search")); +} + +// ── Projects search ───────────────────────────────────────────────────────── + +#[test] +fn projects_search_help_shows_flags() { + lineark() + .args(["projects", "search", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--team")) + .stdout(predicate::str::contains("--limit")); +} + +#[test] +fn usage_includes_projects_search() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("projects search")); +} diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..ec1bd18 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -5,7 +5,7 @@ use assert_cmd::Command; use lineark_sdk::generated::inputs::ProjectCreateInput; -use lineark_sdk::generated::types::{Issue, IssueRelation, Project}; +use lineark_sdk::generated::types::{Document, Issue, IssueRelation, Project}; use lineark_sdk::Client; use predicates::prelude::*; @@ -77,6 +77,24 @@ where Err(last_err) } +/// Like `retry_with_backoff` but with longer delays (5s), for search indexing +/// that can take longer (e.g. document/project search). +fn retry_search_with_backoff(max_attempts: u32, mut f: F) -> Result +where + F: FnMut() -> Result, +{ + let mut last_err = String::new(); + for attempt in 0..max_attempts { + let delay = if attempt == 0 { 2 } else { 5 }; + std::thread::sleep(std::time::Duration::from_secs(delay)); + match f() { + Ok(val) => return Ok(val), + Err(e) => last_err = e, + } + } + Err(last_err) +} + /// RAII guard — permanently deletes a team on drop. /// Ensures cleanup even when the test panics mid-way. struct TeamGuard { @@ -132,6 +150,24 @@ impl Drop for ProjectGuard { } } +/// RAII guard — permanently deletes a document on drop. +struct DocumentGuard { + token: String, + id: String, +} + +impl Drop for DocumentGuard { + fn drop(&mut self) { + let Ok(client) = Client::from_token(self.token.clone()) else { + return; + }; + let id = self.id.clone(); + let _ = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.document_delete::(id).await }); + } +} + test_with::runner!(online); #[test_with::module] @@ -3398,4 +3434,254 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Documents search ──────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn documents_search_json_returns_array() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "documents", + "search", + "test", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "documents search should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + assert!(json.is_array(), "documents search JSON should be an array"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn projects_search_json_returns_array() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "projects", + "search", + "test", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "projects search should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + assert!(json.is_array(), "projects search JSON should be an array"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn documents_search_finds_created_document() { + use lineark_sdk::generated::inputs::DocumentCreateInput; + + let token = api_token(); + let client = Client::from_token(token.clone()).unwrap(); + let rt = tokio::runtime::Runtime::new().unwrap(); + + // Get a team ID for document creation. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "teams", "list"]) + .output() + .unwrap(); + let teams: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let team_id = teams[0]["id"].as_str().unwrap().to_string(); + + // Create a document with unique title using the SDK directly. + // Use a UUID-only title to avoid search tokenization issues. + let unique = format!("xdocsrch{}", uuid::Uuid::new_v4().simple()); + let input = DocumentCreateInput { + title: Some(unique.clone()), + content: Some("CLI search test document.".to_string()), + team_id: Some(team_id), + ..Default::default() + }; + let doc = rt + .block_on(async { client.document_create::(input).await }) + .unwrap(); + let doc_id = doc.id.clone().unwrap(); + let _doc_guard = DocumentGuard { + token: token.clone(), + id: doc_id, + }; + + // Retry search with backoff until the document appears. + // Document search indexing can take longer than issue indexing. + let found = retry_search_with_backoff(15, || { + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "documents", + "search", + &unique, + "--limit", + "10", + ]) + .output() + .map_err(|e| format!("failed to execute: {}", e))?; + if !output.status.success() { + return Err(format!( + "search failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let json: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("invalid JSON: {}", e))?; + let arr = json.as_array().ok_or("expected array")?; + let has_match = arr + .iter() + .any(|item| item["title"].as_str().is_some_and(|t| t.contains(&unique))); + if has_match { + Ok(()) + } else { + Err(format!( + "document not yet found in search (got {} results)", + arr.len() + )) + } + }); + assert!( + found.is_ok(), + "documents search should find the created document: {}", + found.unwrap_err() + ); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn projects_search_finds_created_project() { + use lineark_sdk::generated::inputs::ProjectCreateInput; + + let token = api_token(); + let client = Client::from_token(token.clone()).unwrap(); + let rt = tokio::runtime::Runtime::new().unwrap(); + + // Get a team ID. + let teams_output = lineark() + .args(["--api-token", &token, "--format", "json", "teams", "list"]) + .output() + .unwrap(); + let teams: serde_json::Value = serde_json::from_slice(&teams_output.stdout).unwrap(); + let team_id = teams[0]["id"].as_str().unwrap().to_string(); + + // Create a project with unique name. + let unique = format!("xprojsrch{}", uuid::Uuid::new_v4().simple()); + let input = ProjectCreateInput { + name: Some(unique.clone()), + team_ids: Some(vec![team_id]), + ..Default::default() + }; + let project = rt + .block_on(async { client.project_create::(None, input).await }) + .unwrap(); + let project_id = project.id.clone().unwrap(); + let _project_guard = ProjectGuard { + token: token.clone(), + id: project_id, + }; + + // Retry search with backoff until the project appears. + // Project search indexing can take longer than issue indexing. + let found = retry_search_with_backoff(15, || { + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "projects", + "search", + &unique, + "--limit", + "10", + ]) + .output() + .map_err(|e| format!("failed to execute: {}", e))?; + if !output.status.success() { + return Err(format!( + "search failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let json: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("invalid JSON: {}", e))?; + let arr = json.as_array().ok_or("expected array")?; + let has_match = arr + .iter() + .any(|item| item["name"].as_str().is_some_and(|n| n.contains(&unique))); + if has_match { + Ok(()) + } else { + Err(format!( + "project not yet found in search (got {} results)", + arr.len() + )) + } + }); + assert!( + found.is_ok(), + "projects search should find the created project: {}", + found.unwrap_err() + ); + + // Clean up via guard drop (ProjectGuard). + drop(_project_guard); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn documents_search_with_team_filter() { + let token = api_token(); + + // Get a team key. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "teams", "list"]) + .output() + .unwrap(); + let teams: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let team_key = teams[0]["key"].as_str().unwrap().to_string(); + + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "documents", + "search", + "test", + "--team", + &team_key, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "documents search with --team should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + json.is_array(), + "documents search with --team should return an array" + ); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..afbec9a 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -12,6 +12,8 @@ cycles = true cycle = true issueLabels = true searchIssues = true +searchDocuments = true +searchProjects = true workflowStates = true # Phase 3 — Rich features