use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, bail};
use serde_json::json;
use super::args::Args;
use super::issue::is_identifier;
use crate::auth::token::{Account, TokenStore};
use crate::snapshot::{self as snap, Destination, Shelf, ViewSnapshot};
pub fn run(args: &[String]) -> Result<()> {
let args = Args::parse(args, &["json"], &["workspace"])?;
args.positionals::<0>("linear-tui context [--json] [--workspace <path>]")?;
let cwd = match args.value("workspace") {
Some(path) => PathBuf::from(path),
None => std::env::current_dir()?,
};
let workspace = snap::workspace_of(&cwd);
let found = Shelf::new(&snap::state_dir()?, &workspace).for_context();
let worktree_issue = worktree_issue(&cwd);
if found.is_none() && worktree_issue.is_none() {
bail!(
"No linear-tui view recorded for {} — open linear-tui there first.",
workspace.display()
);
}
let now = snap::unix_now();
let out = if args.flag("json") {
let value = json!({
"workspace": workspace,
"running": found.as_ref().map(|(_, running)| *running),
"worktree_issue": worktree_issue,
"snapshot": found.as_ref().map(|(s, _)| s),
});
serde_json::to_string_pretty(&value)?
} else {
let mut out = render(found.as_ref(), worktree_issue.as_deref(), now);
if let Some(warning) = found
.as_ref()
.and_then(|(s, _)| other_workspace(s, current_account().as_ref()))
{
out.push_str(&format!("\n{warning}\n"));
}
out
};
println!("{}", out.trim_end());
Ok(())
}
fn current_account() -> Option<Account> {
TokenStore::new()
.ok()?
.load()
.ok()?
.current_account()
.cloned()
}
fn other_workspace(s: &ViewSnapshot, current: Option<&Account>) -> Option<String> {
let shown = s.organization.as_ref()?;
let acting = current?.organization.as_ref()?;
(shown.id != acting.id).then(|| {
format!(
"**This view is of {} ({}), but `linear-tui issue …` acts in {} ({}).** \
`linear-tui auth switch {}` first to act on what is shown.",
shown.name, shown.url_key, acting.name, acting.url_key, shown.url_key
)
})
}
pub fn worktree_issue(cwd: &Path) -> Option<String> {
let git = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let dirs = git(&[
"rev-parse",
"--path-format=absolute",
"--git-dir",
"--git-common-dir",
])?;
let mut dirs = dirs.lines();
if dirs.next()? == dirs.next()? {
return None;
}
identifier_in_branch(&git(&["branch", "--show-current"])?)
}
pub fn identifier_in_branch(branch: &str) -> Option<String> {
branch.split('/').find_map(|segment| {
let mut parts = segment.split('-');
let team = parts.next()?;
let number = parts.next()?;
let candidate = format!("{team}-{number}");
is_identifier(&candidate).then(|| candidate.to_ascii_uppercase())
})
}
fn destination_label(dest: &Destination) -> String {
match dest {
Destination::MyIssues => "My Issues".into(),
Destination::Views => "Views".into(),
Destination::View { name, .. } => format!("View “{name}”"),
Destination::Team { team, section } => {
let section = match section {
snap::Section::Issues => "Issues",
snap::Section::Cycles => "Cycles",
snap::Section::Projects => "Projects",
snap::Section::Views => "Views",
};
format!("{} › {section}", team.name)
}
Destination::Favorite { title, .. } => format!("Favorite “{title}”"),
}
}
fn row_line(index: usize, row: &snap::Row, selected: bool) -> String {
let mut line = format!("{}. ", index + 1);
if let Some(identifier) = &row.identifier {
line.push_str(identifier);
line.push(' ');
}
line.push_str(&row.title);
let details: Vec<&str> = [&row.state, &row.assignee, &row.priority]
.into_iter()
.filter_map(|d| d.as_deref())
.collect();
if !details.is_empty() {
line.push_str(&format!(" — {}", details.join(" · ")));
}
if selected {
line.push_str(" ← cursor");
}
line
}
pub fn render(
found: Option<&(ViewSnapshot, bool)>,
worktree_issue: Option<&str>,
now: u64,
) -> String {
let mut out = String::from("# linear-tui context\n\n");
if let Some(issue) = worktree_issue {
out.push_str(&format!(
"This worktree is for **{issue}** — `linear-tui issue show {issue}` for the issue itself.\n\n"
));
}
let Some((s, running)) = found else {
out.push_str("No linear-tui view is recorded for this repository.\n");
return out;
};
let mut path = vec![destination_label(&s.destination)];
let favorite = match &s.destination {
Destination::Favorite { title, .. } => Some(title.as_str()),
_ => None,
};
if let Some(project) = s
.project
.as_ref()
.filter(|p| Some(p.name.as_str()) != favorite)
{
path.push(format!("project “{}”", project.name));
}
if let Some(cycle) = s
.cycle
.as_ref()
.filter(|c| Some(c.name.as_str()) != favorite)
{
path.push(cycle.name.clone());
}
if let Some(issue) = &s.issue {
path.push(issue.identifier.clone());
}
out.push_str(&format!("- Showing: {}\n", path.join(" › ")));
if let Some(org) = &s.organization {
out.push_str(&format!(
"- Linear workspace: {} ({})\n",
org.name, org.url_key
));
}
if let Some(term) = &s.search {
out.push_str(&format!("- Search results for: {term}\n"));
}
let ago = snap::ago(&s.updated_at, now).unwrap_or_else(|| s.updated_at.clone());
let state = if *running {
format!("open (pid {})", s.pid)
} else {
"**closed — this is the last view before it quit, and may be stale**".into()
};
out.push_str(&format!(
"- Updated: {ago} ({}); linear-tui is {state}\n",
s.updated_at
));
if let Some(issue) = &s.issue {
out.push_str(&format!(
"\n## Open issue\n\n{} {} — `linear-tui issue show {}` for its description and comments.\n",
issue.identifier, issue.title, issue.identifier
));
}
if !s.rows.is_empty() {
let heading = if s.issue.is_some() {
"The list it was opened from"
} else {
"List"
};
let shown = if s.rows_total > s.rows.len() {
format!("first {} of {}", s.rows.len(), s.rows_total)
} else {
format!("{}", s.rows_total)
};
out.push_str(&format!("\n## {heading} ({shown})\n\n"));
if let Some(list) = list_settings(s) {
out.push_str(&format!("{list}\n\n"));
}
for (i, row) in s.rows.iter().enumerate() {
out.push_str(&row_line(i, row, s.selected_row == Some(i)));
out.push('\n');
}
}
out.push_str(
"\nAct on these with `linear-tui issue show|comment|status <ID>` and \
`linear-tui issue create --team <key> --title <text>`.\n",
);
out
}
fn list_settings(s: &ViewSnapshot) -> Option<String> {
let source = match (&s.destination, s.screen, &s.project, &s.cycle) {
(_, snap::Screen::ProjectDetail, _, _) => snap::Source::Project,
(_, snap::Screen::CycleDetail, _, _) => snap::Source::Cycle,
(_, snap::Screen::IssueDetail, Some(_), _) => snap::Source::Project,
(_, snap::Screen::IssueDetail, _, Some(_)) => snap::Source::Cycle,
(Destination::MyIssues, ..) => snap::Source::My,
(Destination::View { .. }, ..) => snap::Source::View,
(
Destination::Team {
section: snap::Section::Issues,
..
},
..,
) => snap::Source::Team,
_ => return None,
};
let settings = s.lists.iter().find(|l| l.source == source);
let preset = match settings.map(|l| l.preset) {
Some(snap::Preset::Backlog) => "Backlog",
Some(snap::Preset::All) => "All issues",
Some(snap::Preset::Active) => "Active",
None if source == snap::Source::Team => "Active",
None => "All issues",
};
let mut parts = vec![format!("Preset: {preset}")];
if let Some(status) = settings.and_then(|l| l.status.as_deref()) {
parts.push(format!("Status: {status}"));
}
if let Some(priority) = settings.and_then(|l| l.priority.as_deref()) {
parts.push(format!("Priority: {priority}"));
}
Some(parts.join(" · "))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> ViewSnapshot {
let text = std::fs::read_to_string("tests/fixtures/view_snapshot.json").unwrap();
serde_json::from_str(&text).unwrap()
}
#[test]
fn a_view_of_another_workspace_than_the_issue_commands_is_flagged() {
let snapshot = fixture();
let account = |id: &str| Account {
organization: Some(crate::api::types::Organization {
id: id.into(),
name: "Other".into(),
url_key: "other".into(),
}),
user: None,
tokens: crate::auth::token::OAuthTokens {
access_token: String::new(),
refresh_token: String::new(),
expires_at: 0,
},
};
let warning = other_workspace(&snapshot, Some(&account("elsewhere"))).unwrap();
assert!(
warning.contains("`linear-tui auth switch shop`"),
"{warning}"
);
assert!(other_workspace(&snapshot, Some(&account("b41d…org"))).is_none());
assert!(other_workspace(&snapshot, None).is_none());
}
#[test]
fn a_branch_names_its_issue() {
assert_eq!(
identifier_in_branch("me/eng-42-checkout-fails").as_deref(),
Some("ENG-42")
);
assert_eq!(
identifier_in_branch("ENG-42/fix").as_deref(),
Some("ENG-42")
);
assert_eq!(identifier_in_branch("feat/view-snapshot"), None);
assert_eq!(identifier_in_branch("main"), None);
}
#[test]
fn the_markdown_says_what_is_open_and_marks_the_cursor() {
let snapshot = fixture();
let now = snap::parse_timestamp(&snapshot.updated_at).unwrap() + 120;
let text = render(Some(&(snapshot, true)), Some("ENG-42"), now);
assert!(text.contains("This worktree is for **ENG-42**"), "{text}");
assert!(
text.contains("- Showing: Engineering › Issues › ENG-42\n"),
"{text}"
);
assert!(text.contains("2 minutes ago"), "{text}");
assert!(text.contains("- Linear workspace: Shop (shop)\n"), "{text}");
assert!(text.contains("linear-tui is open (pid 48213)"), "{text}");
assert!(
text.contains("## The list it was opened from (2)"),
"{text}"
);
assert!(text.contains("Preset: Active · Priority: High"), "{text}");
assert!(
text.contains("1. ENG-40 Retry payment webhooks — In Progress · me · High\n"),
"{text}"
);
assert!(
text.contains("2. ENG-42 Checkout fails on empty cart — Todo · High ← cursor\n"),
"{text}"
);
}
#[test]
fn a_closed_instance_is_flagged_as_stale() {
let snapshot = fixture();
let now = snap::parse_timestamp(&snapshot.updated_at).unwrap() + 7200;
let text = render(Some(&(snapshot, false)), None, now);
assert!(text.contains("may be stale"), "{text}");
assert!(text.contains("2 hours ago"), "{text}");
assert!(!text.contains("This worktree"), "{text}");
}
#[test]
fn a_worktree_issue_alone_is_still_context() {
let text = render(None, Some("ENG-7"), 0);
assert!(text.contains("**ENG-7**"), "{text}");
assert!(text.contains("No linear-tui view is recorded"), "{text}");
}
}