From 384753025168cae2383d9aa39a4ec7d457473757 Mon Sep 17 00:00:00 2001
From: Duc-Tam Nguyen <tamnd87@gmail.com>
Date: Tue, 16 Jun 2026 11:28:07 +0700
Subject: [PATCH] ossucs: add list/course/info commands

---
 ossucs/domain.go    | 75 +++++++++++++++++++++++++++++++++-
 ossucs/info_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++++
 ossucs/ossucs.go    | 48 ++++++++++++++++++++++
 ossucs/types.go     |  8 ++++
 4 files changed, 228 insertions(+), 1 deletion(-)
 create mode 100644 ossucs/info_test.go

diff --git a/ossucs/domain.go b/ossucs/domain.go
index 98ec96f..93b1ea4 100644
--- a/ossucs/domain.go
+++ b/ossucs/domain.go
@@ -2,6 +2,8 @@ package ossucs
 
 import (
 	"context"
+	"fmt"
+	"strconv"
 
 	"github.com/tamnd/any-cli/kit"
 	"github.com/tamnd/any-cli/kit/errs"
@@ -48,10 +50,24 @@ key, nothing to run alongside it.`,
 func (Domain) Register(app *kit.App) {
 	app.SetClient(newClient)
 
-	// List op: enumerate all courses in the OSSU CS curriculum.
+	// list: enumerate all courses (alias for courses).
+	kit.Handle(app, kit.OpMeta{Name: "list", Group: "read", List: true,
+		Summary: "List courses (optionally filtered by section)",
+		URIType: "course"}, listCoursesFiltered)
+
+	// courses: enumerate all courses.
 	kit.Handle(app, kit.OpMeta{Name: "courses", Group: "read", List: true,
 		Summary: "List all courses in the OSSU Computer Science curriculum",
 		URIType: "course"}, listCourses)
+
+	// course: one course by 1-based index.
+	kit.Handle(app, kit.OpMeta{Name: "course", Group: "read", Single: true,
+		Summary: "Show one course by index",
+		Args:    []kit.Arg{{Name: "index", Help: "1-based course index"}}}, getCourse)
+
+	// info: site-level stats.
+	kit.Handle(app, kit.OpMeta{Name: "info", Group: "read", Single: true,
+		Summary: "Print site stats (total courses, sections)"}, getInfo)
 }
 
 // newClient builds the client from the host-resolved config, so a host and the
@@ -80,6 +96,21 @@ type coursesInput struct {
 	Client *Client `kit:"inject"`
 }
 
+type coursesFilteredInput struct {
+	Section string  `kit:"flag" help:"filter by section name"`
+	Limit   int     `kit:"flag,inherit" help:"max results"`
+	Client  *Client `kit:"inject"`
+}
+
+type courseInput struct {
+	Index  string  `kit:"arg" help:"1-based course index"`
+	Client *Client `kit:"inject"`
+}
+
+type infoInput struct {
+	Client *Client `kit:"inject"`
+}
+
 // --- handlers ---
 
 func listCourses(ctx context.Context, in coursesInput, emit func(*Course) error) error {
@@ -98,6 +129,48 @@ func listCourses(ctx context.Context, in coursesInput, emit func(*Course) error)
 	return nil
 }
 
+func listCoursesFiltered(ctx context.Context, in coursesFilteredInput, emit func(*Course) error) error {
+	var courses []*Course
+	var err error
+	if in.Section != "" {
+		courses, err = in.Client.CoursesBySection(ctx, in.Section)
+	} else {
+		courses, err = in.Client.Courses(ctx)
+	}
+	if err != nil {
+		return mapErr(err)
+	}
+	for i, c := range courses {
+		if in.Limit > 0 && i >= in.Limit {
+			break
+		}
+		if err := emit(c); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func getCourse(ctx context.Context, in courseInput, emit func(*Course) error) error {
+	idx, err := strconv.Atoi(in.Index)
+	if err != nil {
+		return errs.Usage("index must be an integer: %s", err)
+	}
+	c, err := in.Client.CourseByIndex(ctx, idx)
+	if err != nil {
+		return fmt.Errorf("%w", err)
+	}
+	return emit(c)
+}
+
+func getInfo(ctx context.Context, in infoInput, emit func(*Info) error) error {
+	info, err := in.Client.Info(ctx)
+	if err != nil {
+		return mapErr(err)
+	}
+	return emit(info)
+}
+
 // --- Resolver ---
 
 // Classify turns a reference into (type, id). Courses are addressed by rank.
diff --git a/ossucs/info_test.go b/ossucs/info_test.go
new file mode 100644
index 0000000..c81bf44
--- /dev/null
+++ b/ossucs/info_test.go
@@ -0,0 +1,98 @@
+package ossucs_test
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/tamnd/ossucs-cli/ossucs"
+)
+
+const fakeReadme2 = `## Intro CS
+
+| Courses | Duration | Effort |
+| :--: | :--: | :--: |
+[Introduction to Python](https://example.com/python) | 14 weeks | 10 hrs/week
+[Intro to CS](https://example.com/cs) | 10 weeks | 5 hrs/week
+
+### Core programming
+
+[Systematic Program Design](coursepages/spd/README.md) | 13 weeks | 8 hrs/week
+[Class-based Program Design](https://example.com/class) | 13 weeks | 5 hrs/week
+`
+
+func newOSSUTestClient(ts *httptest.Server) *ossucs.Client {
+	cfg := ossucs.DefaultConfig()
+	cfg.BaseURL = ts.URL
+	cfg.Rate = 0
+	return ossucs.NewClient(cfg)
+}
+
+func TestCourseByIndex(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, fakeReadme2)
+	}))
+	defer ts.Close()
+
+	c := newOSSUTestClient(ts)
+	co, err := c.CourseByIndex(context.Background(), 2)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if co.Rank != 2 {
+		t.Errorf("Rank = %d, want 2", co.Rank)
+	}
+	if co.Title != "Intro to CS" {
+		t.Errorf("Title = %q", co.Title)
+	}
+}
+
+func TestCourseByIndexNotFound(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, fakeReadme2)
+	}))
+	defer ts.Close()
+
+	c := newOSSUTestClient(ts)
+	_, err := c.CourseByIndex(context.Background(), 999)
+	if err == nil {
+		t.Error("expected error for missing index")
+	}
+}
+
+func TestCoursesBySection(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, fakeReadme2)
+	}))
+	defer ts.Close()
+
+	c := newOSSUTestClient(ts)
+	courses, err := c.CoursesBySection(context.Background(), "intro cs")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(courses) != 2 {
+		t.Fatalf("want 2 courses in Intro CS, got %d", len(courses))
+	}
+}
+
+func TestInfo(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, fakeReadme2)
+	}))
+	defer ts.Close()
+
+	c := newOSSUTestClient(ts)
+	info, err := c.Info(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if info.Courses != 4 {
+		t.Errorf("Courses = %d, want 4", info.Courses)
+	}
+	if info.Sections != 2 {
+		t.Errorf("Sections = %d, want 2", info.Sections)
+	}
+}
diff --git a/ossucs/ossucs.go b/ossucs/ossucs.go
index 578682b..ea2f799 100644
--- a/ossucs/ossucs.go
+++ b/ossucs/ossucs.go
@@ -103,6 +103,54 @@ func (c *Client) Courses(ctx context.Context) ([]*Course, error) {
 	return courses, nil
 }
 
+// CourseByIndex returns the course at the given 1-based rank.
+func (c *Client) CourseByIndex(ctx context.Context, index int) (*Course, error) {
+	courses, err := c.Courses(ctx)
+	if err != nil {
+		return nil, err
+	}
+	for _, co := range courses {
+		if co.Rank == index {
+			return co, nil
+		}
+	}
+	return nil, fmt.Errorf("course at index %d not found (total: %d)", index, len(courses))
+}
+
+// CoursesBySection returns courses filtered by section name (case-insensitive partial match).
+func (c *Client) CoursesBySection(ctx context.Context, section string) ([]*Course, error) {
+	courses, err := c.Courses(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := strings.ToLower(section)
+	var out []*Course
+	for _, co := range courses {
+		if strings.Contains(strings.ToLower(co.Section), q) {
+			out = append(out, co)
+		}
+	}
+	return out, nil
+}
+
+// Info returns site-level stats.
+func (c *Client) Info(ctx context.Context) (*Info, error) {
+	courses, err := c.Courses(ctx)
+	if err != nil {
+		return nil, err
+	}
+	sections := map[string]bool{}
+	for _, co := range courses {
+		sections[co.Section] = true
+	}
+	return &Info{
+		Site:     "github.com/ossu/computer-science",
+		Courses:  len(courses),
+		Sections: len(sections),
+		Source:   c.cfg.BaseURL + "/ossu/computer-science/master/README.md",
+	}, nil
+}
+
 // get fetches a path under BaseURL and returns the response body. It paces and
 // retries according to the client's settings.
 func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
diff --git a/ossucs/types.go b/ossucs/types.go
index fc164d5..98ce2ec 100644
--- a/ossucs/types.go
+++ b/ossucs/types.go
@@ -7,3 +7,11 @@ type Course struct {
 	Title   string `json:"title"   csv:"title"   tsv:"title"`
 	URL     string `json:"url"     csv:"url"     tsv:"url"`
 }
+
+// Info is site-level stats.
+type Info struct {
+	Site     string `json:"site"     csv:"site"     tsv:"site"`
+	Courses  int    `json:"courses"  csv:"courses"  tsv:"courses"`
+	Sections int    `json:"sections" csv:"sections" tsv:"sections"`
+	Source   string `json:"source"   csv:"source"   tsv:"source"`
+}
